A batteries-included Kotlin primitives library — the Kotlin/JVM (and, where it makes sense,
Android) port of primitives-go: observability, HTTP, resilience, caching,
crypto, analytics, feature flags, push and a couple dozen more — each behind a small, consistent
interface, traced and log-correlated end to end.
Scope: the client. Kotlin builds apps here, never services — platform-go is the only server
tier there is — so this library carries nothing whose reason to exist is sitting beside a database,
a broker, an object store or a secret manager. Talking to a service built on platform-go is
platform-client-kt's job, not this library's.
Identifiers are the case worth naming, because the package name does not sound server-tier: a
server issues IDs and a client receives opaque strings, so minting one here only risks having
it rejected by the service you send it to. There is no :identifiers module.
Status: scoped to the client tier — 44 Gradle modules, building, testing and linting green. Parity with
primitives-gois deliberately partial; see below. Pure-JVM modules are consumable from any server/JVM project with no Android tooling (see Using primitives-kt).
Jump to: What it gives you · Quick taste · Module catalog · Using it · Building · Guides: cookbook · observability · porting status
Every package follows the same anatomy, so once you've learned one you've learned them all:
- An
-apimodule — the interface you program against, a config DSL thatvalidate()s at construction (so misconfiguration fails loudly at startup, not silently at first use), andnoop+mockdoubles for tests. - One or more backend modules — the real implementation over a vendor SDK (Redis, Postgres, S3,
Stripe, Anthropic, …). Additional vendors are documented
TODO(<vendor>)seams, not silent gaps.
Three properties run through the whole tree:
- Traced & correlated. A value you record on an operation lands on both its trace span and a
span-linked logger, and downstream HTTP/DB/queue calls parent under it automatically — via the
coroutine context, with no
ctxparameter to thread. Observability is the founding pillar everything else plugs into. - Resilient by construction. Every remote call accepts an optional circuit breaker; retries, backoff, and rate limiting are first-class and coroutine-native.
- Noops everywhere. Telemetry, breakers, and backends all degrade to no-ops when absent — nothing throws for want of a logger. You wire in exactly what you need.
Dual-target. Kotlin runs on the JVM and Android, so the pure-JVM modules serve desktop and
tooling consumers while a handful of packages add an Android counterpart. Note that several of these
are the receiving half of a name that means the opposite in primitives-go: eventstream
consumes SSE/WS into a Flow, notifications receives push rather than sending it, and
uploads is the signed-URL client uploader, not an object-store sink.
The founding pillar — one Observer per component, a span { } per traced call, and a set that
writes to the span and the correlated log line at once:
class UserRepository(observers: ObserverFactory, private val api: UserApi) {
private val o11y = observers.named("user_repository")
suspend fun load(userId: String): User = o11y.span("load") {
set(Keys.USER_ID to userId) // lands on the span AND the span-linked logger
logger.debug("loading user")
api.fetch(userId) // if api is instrumented, its span nests under "load"
} // span ends on return; any throw is recorded + logged
}A few more packages, to show the house style (full cookbook in docs/EXAMPLES.md):
// Resilient, traced HTTP — OkHttp (client/Android) or Ktor (server) behind one interface
val http: HttpClient = OkHttpHttpClient.create(HttpClientConfig { timeout = 5.seconds })
val resp = http.execute(HttpRequest.get("https://api.example.com/health"))
// A circuit breaker around a flaky dependency
val breaker = CircuitBreaker { failureThreshold = 5; resetTimeout = 10.seconds }
val data = breaker.execute { dependency.call() } // throws ErrCircuitBroken while OPEN
// A typed cache — swap InMemoryCache for the DataStore-backed one, same interface
val cache: BatchCache<String> = InMemoryCache()
cache.set("greeting", "hello"); cache.get("greeting") // "hello"
// A token bucket, so you throttle before you spend the request
val limiter = InMemoryRateLimiter(requestsPerSecond = 10.0, burst = 20)
if (limiter.allow("sync")) syncNow()44 modules, grouped by the tier they were ported in. Depend on just the ones you need — every JVM
module depends only on other JVM modules. Coordinates are
com.github.primandproper.primitives-kt:<module>:<tag> (see Using it).
| Module | Type | What |
|---|---|---|
:observability-api |
JVM | The contract: Observer, Operation, Logger, Tracer, span { }, Keys, config DSL, noop backends. |
:observability-otel |
JVM | OTel SDK + OTLP exporter + head sampling + W3C propagation. |
:observability-logcat |
Android | LogcatLogger over android.util.Log. |
:observability-koin |
JVM | Optional Koin module for DI. |
:observability-testing |
JVM | RecordingObserver test double + framework-neutral matchers. |
:observability |
Android | Umbrella Observability { } builder that wires the backends. |
Full walkthrough: docs/USAGE.md.
The small, widely-depended-on substrate. All pure Kotlin/JVM, coroutine-native, traced.
| Module | What |
|---|---|
:errors |
Sentinels + wrap/isError/asError; HTTP ErrorCode→toApiError/toHttpStatus; gRPC GrpcCode mapping. Owns ErrCircuitBroken. |
:random |
Crypto-secure random strings/bytes over SecureRandom (hex/Base32/Base64url/custom alphabet). |
:retry |
Coroutine-native exponential backoff + jitter: Policy.execute { } and a Flow.retryWithPolicy variant. |
:circuitbreaking |
execute { } breaker with a CircuitState StateFlow, plus a partitioned per-key registry. |
:httpclient-api |
The HTTP contract: request/response models, config DSL, retry hook, header redaction, OTel span recording. |
:httpclient-okhttp / :httpclient-ktor |
OkHttp (client/Android) and Ktor/CIO (server) backends behind that one contract. |
Each ships core -api + one primary backend + a real Android module.
| Module(s) | What |
|---|---|
:cache-api · :cache-android |
Typed Cache/BatchCache: in-memory + DataStore on Android. |
:cryptography-api · :cryptography-jvm · :cryptography-android |
AES-256-GCM AEAD + SHA/checksum hashers; AndroidKeyStore backend. |
:secrets-api · :secrets-android |
SecretSource: env provider (value never touches a span/log) + EncryptedSharedPreferences. |
:featureflags-api · :featureflags-launchdarkly · :featureflags-android |
Flag evaluation: in-memory + LaunchDarkly (breaker-wrapped) + local store. |
:analytics-api · :analytics-segment · :analytics-android |
Event reporting: Segment + multisource composite + on-device buffering. |
What survived the server-tier cut: the pieces a client still needs on its own side of the wire.
| Module(s) | What |
|---|---|
:ratelimiting-api |
Token-bucket RateLimiter, in-memory — client-side throttling before you spend a request. |
:uploads-api · :uploads-android |
UploadManager/Bucket: in-mem/filesystem + signed-URL client uploader. |
:eventstream-api · :eventstream-android |
SSE/WS consumed into a Flow on Android. |
:cookies · :encoding |
AES-GCM sealed cookies · kotlinx.serialization JSON. |
| Module(s) | What |
|---|---|
:notifications-api · :notifications-fcm · :notifications-android |
Push via FCM HTTP v1 (send) + Android receive-side mapping. |
:qrcodes |
ZXing PNG QR codes for otpauth:// URIs (pairs with TOTP). |
:compression · :files · :numbers · :bitmask |
zstd/S2 · nio line/chunk readers · BigDecimal helpers · width-typed bitmasks. |
:version · :fake · :testutils |
Build/VCS metadata · seedable data generator · Testcontainers helpers. |
The authoritative per-package status — which backends shipped and which vendor seams remain open — is docs/PORTING_STATUS.md.
primitives-kt publishes through JitPack — no artifact registry to log into and no credentials. To cut a release, make the GitHub repo public and push a tag:
git tag v0.1.0 && git push origin v0.1.0Then, in the consuming project, add the JitPack repository and depend on the modules you want by
coordinate — com.github.primandproper.primitives-kt:<module>:<tag>:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
mavenCentral()
maven("https://jitpack.io")
}
}
// build.gradle.kts
dependencies {
implementation("com.github.primandproper.primitives-kt:cache-api:v0.1.0")
}JitPack builds the tag on first request (later resolves are cached and fast). It compiles only the
pure-JVM modules — the Android-library modules are excluded via -PjvmOnly (see jitpack.yml and
the toggle in settings.gradle.kts) — so a JVM project consumes primitives-kt with no Android
tooling on either side, and JitPack itself never needs the Android SDK. Every JVM module depends
only on other JVM modules, so no .aar is ever pulled into a server build.
Ten packages ship a real Android-library counterpart. They carry the same group and version scheme
as the JVM modules — com.github.primandproper.primitives-kt:<module>:<tag> — but package as .aar:
| Module (artifact ID) | What |
|---|---|
observability-logcat |
LogcatLogger over android.util.Log. |
observability |
Umbrella Observability { } builder that wires the backends. |
secrets-android |
SecretSource over EncryptedSharedPreferences. |
analytics-android |
On-device event buffering. |
featureflags-android |
Local flag store. |
cryptography-android |
AES-256-GCM AEAD over the AndroidKeyStore. |
cache-android |
Disk-backed BatchCache on Jetpack DataStore. |
uploads-android |
Signed-URL client uploader. |
eventstream-android |
Consumes SSE/WS into a Flow. |
notifications-android |
FCM receive-side mapping. |
These .aars compile to Java 17 bytecode (sourceCompatibility/targetCompatibility = 17), so
consuming them requires AGP 8.x (which understands Java-17 class files) and a project that itself
compiles against Java 17.
Core-library desugaring is required on minSdk < 26. Every Android module transitively exposes
opentelemetry-api (via :observability-api), which references java.time / java.util.function —
APIs only present natively from API 26. All ten modules ship with
isCoreLibraryDesugaringEnabled = true, but desugaring is a consumer-side transform: your app module
must also enable it, or java.time usage will crash at runtime on pre-26 devices.
android {
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.3")
}Per-module minSdk (the shared floor is 21; two modules raise it for the AndroidKeyStore):
| Module | minSdk |
Desugaring needed (minSdk < 26) |
|---|---|---|
observability |
21 | Yes |
observability-logcat |
21 | Yes |
analytics-android |
21 | Yes |
cache-android |
21 | Yes |
eventstream-android |
21 | Yes |
featureflags-android |
21 | Yes |
uploads-android |
21 | Yes |
notifications-android |
21 | Yes |
secrets-android |
23 | Yes |
cryptography-android |
23 | Yes |
secrets-android and cryptography-android require API 23 because EncryptedSharedPreferences /
MasterKey and the AndroidKeyStore AES-256-GCM key scheme are only available from API 23.
JitPack serves the JVM modules (its build runs -PjvmOnly, so it never needs the Android SDK). The
ten Android .aar modules are published separately to this repo's GitHub Packages Maven registry
by a tag-triggered CI job (.github/workflows/publish.yml): pushing a v* tag runs the full
(non-jvmOnly) publishAllPublicationsToGitHubPackagesRepository at the tag's version, so a failure
there can never regress JitPack's reliable JVM flow.
Consume them by adding the authenticated GitHub Packages repository (GitHub requires a token even for public packages) and depending on the same coordinate as the JVM modules:
repositories {
maven {
url = uri("https://maven.pkg.github.com/primandproper/primitives-kt")
credentials {
username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GITHUB_ACTOR")
password = providers.gradleProperty("gpr.token").orNull ?: System.getenv("GITHUB_TOKEN")
}
}
}
dependencies {
implementation("com.github.primandproper.primitives-kt:cryptography-android:v0.1.0")
}For pre-tag experimentation, a local checkout + ./gradlew publishToMavenLocal (which builds the
.aars — see Local iteration) consumed via mavenLocal() still
works and needs no credentials.
To try changes before tagging, publish to your Maven Local repo and consume via mavenLocal():
./gradlew publishToMavenLocal # every module, incl. Android .aar (needs the Android SDK)
./gradlew publishToMavenLocal -PjvmOnly # JVM modules only, no Android SDK requiredThe coordinate is the same com.github.primandproper.primitives-kt:<module> either way, so your
implementation(...) lines are identical for JitPack and Maven Local — only the repository differs.
Requires JDK 17 and the Android SDK (local.properties with sdk.dir, or ANDROID_HOME).
Easiest path is to open the project in Android Studio. The Gradle wrapper is committed, so ./gradlew
works out of the box — from the CLI just use the Makefile:
make build # compile + assemble
make fmt # ktlintFormat
make lint # ktlintCheck
make test # JVM unit tests
make check # lint + format + tests — same command CI runsRun make help for the full target list. Every PR is gated by
.github/workflows/ci.yml, which runs make check (ktlint + unit tests +
Android Lint) — the same target you run locally, so a green make check predicts a green CI run.
- Context propagation is implicit.
span { }installs the span into the coroutine context via OTel'sContext.asContextElement(), so nestedsuspendcalls parent correctly across dispatcher threads.spanBlocking { }is the thread-local variant for non-coroutine call sites (WorkManager, callbacks, Java interop). - Explicit operation names. There's no cheap analog of Go's
runtime.Callerscaller-naming under Kotlin inlining, sospan("name")takes the name. Terse, and zero reflection on the hot path. - Noops everywhere. A nil logger or tracer degrades to a noop; a non-recording span makes every
set/errora no-op. Nothing panics for want of observability. - OTel
Spanis aliased, not re-abstracted (typealias Span = io.opentelemetry.api.trace.Span), exactly as platform-go aliasestrace.Span. - Faithful to the source. Ports are validated by comparison against
../platform-go, not by behavioural equivalence testing against the Go binaries. Deliberate divergences (coroutine-context propagation, AES-GCM cookie sealing vs gorilla's wire format, Snappy-framed S2) are documented where they occur.
- Metrics & profiling pillars — named, not silently dropped. When metrics lands,
Observability/ObservabilityConfig/provider enums extend back toward Go's 4-pillar shape, and theTODO(metrics)seams acrosscircuitbreaking/cache/ratelimiting/… reattach. - Three Go idioms with no worthwhile Kotlin analog —
pointer(Kotlin nullability obviates it),reflection, andpanicking(exceptions +runCatchingcover it).