Skip to main content

kiit-service-id logokiit-service-id

Shared identity vocabulary for a service: who it is, what kind of thing it is, and a caller-safe way to expose it.

A typed service identity with ownership, kind, environment, version and instance, a private and an external form, and attribute names that line up with OpenTelemetry's service attributes. Kotlin Multiplatform, with a native TypeScript port.

Think of a name for a service
  1. Structured: A name like acme:accounts.signup:api instead of a free string each subsystem makes up.
  2. Safe to expose: externalId carries no version, environment or instance.
  3. Tiny: Zero dependencies, so any subsystem can use it.
Placeholder diagram

Overview​

Goals​

Calls and services are hard to identify. This is most true of HTTP requests and of internal service to service calls. There is also no shared convention for naming a service. OpenTelemetry improved this with service attributes for telemetry, and kiit-service-id takes the idea a step further by putting those attributes in a static type and an interface.

The goals are:

  1. A contract for a service id, with attributes that describe the service.
  2. Attributes for ownership, service type, environment, version and more.
  3. Compatibility with OpenTelemetry service attributes, for telemetry integration.
  4. Security through two forms of the id: an external one (externalId) and a private one (privateId).
  5. A tiny, zero dependency library for Kotlin Multiplatform, with a native TypeScript port.

Features​

#FeatureDescription
1Identity contractIServiceId is the plain data contract, and ServiceId is the concrete type you build.
2Identity chainpath, name, fullName, install and privateId, each adding one field to the one before.
3External formexternalId is safe to send outside your trust boundary.
4Descriptive attributesOwnership (origin, team), Kind, environment, version, criticality, about, tags and uri.
5OpenTelemetry compatibleThe fields line up with OpenTelemetry service attributes.
6ParsingRebuild an identity from a caller-id header value.
7Zero dependenciesKotlin Multiplatform, plus a native TypeScript port.

Inspiration​

#SourceWhat was drawn from it
1OpenTelemetry service attributesThe attribute names, the criticality values, and the idea of a per instance id and a version.
2kiit-codesThe origin and scope convention, shared with Status.origin and Status.scope.

Activity​

Used in production for years, as the original kiit version. This repository is that component extracted into its own module.

Resources​

Prefer to see it work first? Jump to the Tutorial. Prefer the reasoning first? Keep reading.

Setup​

Install​

Imports​

What to import to use the library.

import kiit.serviceid.Criticality
import kiit.serviceid.Kind
import kiit.serviceid.Provenance
import kiit.serviceid.ServiceId
import kiit.serviceid.Tag

Source​

Example​

Build an identity and read three forms of it:

val id = ServiceId.api(origin = "acme", scope = "accounts.signup", env = "qat")

println(id.name) // acme:accounts.signup:api
println(id.install) // acme:accounts.signup:api:qat:latest
println(id.externalId) // acme:accounts.signup

Explanation​

Terms​

#NameTypeExampleNotes
1originStringacmeWho owns the identity. A domain like label, the same convention as Status.origin in kiit-codes. Not validated.
2scopeStringaccounts.signupWhere in origin it lives. Dots express hierarchy. It can't contain a colon.
3kindKindKind.APIThe kind of runnable thing this is. See Kind values.
4envStringqatThe environment, as whatever label you already use.
5versionString1.4.2The version running here. Defaults to latest.
6instanceString4a3b300bOne running instance, to tell copies of the same version apart. Random by default.
7aboutStringSends the welcome emailA short description of what the service does. Empty means unset.
8tagsList<Tag>Tag.Keyed("region", "us-east-1")Labels on the identity: a bare value or a key and value.
9uriString?worker-7.acme.internalA reference to the instance itself, such as a hostname. Unique per environment.
10criticalityCriticalityCriticality.HighHow much it matters if the service fails or is unavailable.
11teamStringpayments-platformThe team that owns the service, such as a Slack team or a distribution list. Distinct from origin, the owning organization.
12provenanceProvenanceProvenance.DeclaredHow the identity came to exist: Declared when built with of, Parsed when rebuilt from a string.

Descriptive attributes. about, tags, uri, criticality, team and provenance are descriptive. They are never part of an identifier, and the library has no health or ownership logic that acts on them.

Kind. Kind is a closed set of things that are runnable and deployable and that issue or serve requests. A new kind is added only when it changes how a caller is attributed or handled. Gateway marks the edge where privateId stops and externalId starts. Function marks short lived instances that run per request. The ones left out are covered by an existing kind, or are not a caller at all:

  1. A scheduler or cron job is Job.
  2. A queue or stream consumer is Worker.
  3. A mobile or desktop app is App, and a browser frontend is Web.
  4. A plugin or extension runs inside another process and has no identity of its own.
  5. A database, cache or queue is called but never calls, so it is not at the edge of a request.

Environment. env is a plain string, so the library does not enforce how you name environments.

Tags. A Tag is either a bare value or a key and value. Tag.parse splits on the first =. The delimiter is = and not : so it doesn't clash with the : that builds the chain. Tags are stored as given and not normalized. Tag lives in this module, and kiit-requests still has its own until it migrates.

Identity chain​

Each accessor adds one field to the one before it:

  1. path is who: origin and scope.
  2. name adds the kind.
  3. fullName adds the env.
  4. install adds the version.
  5. privateId adds the instance.

Each level answers a different question: this component, in one environment, at one version, as one running instance. The cost is six fixed segments, so : is reserved. See the values in Accessors.

Security​

externalId is an alias for path. It carries only origin and scope, with no operational detail, so it is the form to expose. privateId carries the exact version, environment and instance, and belongs inside your trust boundary. Outside it, those details are reconnaissance material, the same kind of risk as leaving a Server or X-Powered-By header exposed. See OWASP's guidance on fingerprinting a web application framework.

val id = ServiceId.of("acme", "accounts.signup", Kind.API, "qat", version = "1.4.2", instance = "4a3b300b")

println(id.privateId) // acme:accounts.signup:api:qat:1.4.2:4a3b300b, for internal calls only
println(id.externalId) // acme:accounts.signup, safe to send outside

The cost is that callers have to choose. Nothing stops code from sending privateId outside, so keeping it internal is a convention and not a check.

Keep privateId inside
  1. Internal only: Use privateId for trusted service to service traffic.
  2. Outside: Send externalId when a call leaves your infrastructure.
  3. Not for decisions: Don't let a parsed privateId decide authorization for a request that could have come from outside.

Construction​

ServiceId.of and its shortcuts are the way to build an identity. The constructor is internal.

Normalization. of lowercases origin, scope, env and version, and strips every character except letters, digits, -, _, . and spaces, turning spaces into _. instance is validated and not normalized. Folding its case could make two different instances collide, so of only checks that it has no :.

Immutability. with() and newInstance() return a new value, so an identity can be shared without being changed. The cost is that copy() is internal too, so code inside the module can still make an un-normalized copy. The library accepts that gap rather than give up data class.

Equality. Two identities are equal when their privateId is equal. about, tags, uri, criticality, team and provenance don't count. The reason is that a server treats two requests as the same caller when the caller-id header value matches, and nothing else travels on the wire. equals, hashCode and toString are overridden together to match.

val first = ServiceId.job("acme", "accounts.signup")
val second = first.newInstance()

println(first == second) // false: a new instance id gives a new privateId
println(first.path == second.path) // true: everything above the instance is the same

Contract and implementation​

IServiceId is a bare data contract. The derived accessors (path, name, fullName, install, privateId and externalId) live only on ServiceId. Implementing IServiceId for a custom shape shouldn't promise accessors that shape never defined. The cost is that a custom implementation gets none of them.

Parsing​

ServiceId.parse rebuilds an identity from a privateId string, such as a caller-id header value. It is strict and matches of and with, which also reject bad input:

  1. It needs exactly six segments, a known kind and no blank segment.
  2. It lowercases origin, scope, kind, env and version, so a parsed identity holds the same field values as one built with of.
  3. It rejects, and does not rewrite, a segment with characters of would strip, so untrusted header text is never silently changed into something else.
  4. It leaves instance as given, the same as of.

Only the six chain fields come back. The descriptive fields get their defaults, and provenance is Parsed.

val parsed = ServiceId.parse("acme:accounts.signup:api:qat:1.4.2:4a3b300b")

println(parsed.provenance) // Parsed
println(parsed.tags) // []

OpenTelemetry​

OpenTelemetry's resource describes the producer of telemetry. It is set once per process and attached to every signal. A ServiceId is a value that travels in requests, is parsed and compared, and keys caches and jobs. criticality is borrowed from OpenTelemetry's service.criticality.

It is not built on the resource model, for three reasons:

  1. Different identity: OpenTelemetry treats deployment.environment.name and service.version as not part of a service's identity. ServiceId equality is privateId, which includes both, because a server sees that string in a caller-id header and two builds are different callers.
  2. Different shape: A resource is an open attribute map. ServiceId has fixed fields, derived string forms and a strict parse, and it adds kind, externalId and provenance, which resources don't have.
  3. No dependency: The module has none, and a resource model would add one to every subsystem that uses it.

The service fields do map onto resource attributes. A one way conversion belongs in a separate telemetry adapter and not in this module. The open choice is origin against service.namespace: origin is the owning organization, and OpenTelemetry's namespace is a grouping inside it. See the table in OpenTelemetry mapping.

Limitations​

  1. The trust boundary is a convention, not a check.
  2. Tags are stored as given, not normalized.
  3. Kind is a closed set.

Tutorial​

Create​

This walks through creating an identity, sending it between two services, and reading it on the other side.

Start here
  1. Start here: Build an identity with a shortcut such as ServiceId.api.
  2. Add as needed: Reach for ServiceId.of when you need a kind or an option the shortcuts don't cover.

Create an identity and print each form of it:

val id = ServiceId.api("acme", "accounts.signup", "qat")

println("path=${id.path}")
println("name=${id.name}")
println("fullName=${id.fullName}")
println("install=${id.install}")
println("privateId=${id.privateId}")
println("externalId=${id.externalId}")

Copy​

An identity never changes. with returns a new one, here with two tags:

val original = ServiceId.job("acme", "accounts.signup")
val tagged = original.with(inst = null, tags = listOf(Tag.Basic("retry"), Tag.Keyed("batch", "42")))

println("original tags=${original.tags}") // []
println("tagged tags=${tagged.tags}")

Send​

Send the private id to another internal service in a caller-id header:

val headers = mapOf("caller-id" to caller.privateId)

println(headers["caller-id"]) // acme:accounts.signup:api:qat:1.4.2:4a3b300b

Receive​

On the receiving side, parse the header back into an identity and read its fields:

val received = ServiceId.parse(headers.getValue("caller-id"))

println(received.origin) // acme
println(received.scope) // accounts.signup

Send outside​

When a call leaves your infrastructure, send the external id instead:

val outbound = mapOf("caller-id" to caller.externalId)

println(outbound["caller-id"]) // acme:accounts.signup

Guide​

Choose a kind​

Only app, api, cli, job and test have shortcuts. For any other kind, such as a frontend (Web), a gateway or an AI agent, build the identity with ServiceId.of. See Terms for how to choose between kinds.

val frontend = ServiceId.of("acme", "storefront", Kind.Web, "pro")
val edge = ServiceId.of("acme", "edge", Kind.Gateway, "pro")
val assistant = ServiceId.of("acme", "support.assistant", Kind.Agent, "pro")

println(frontend.name) // acme:storefront:web
println(edge.name) // acme:edge:gateway
println(assistant.name) // acme:support.assistant:agent

Strip at the gateway​

At the edge, replace the internal id with the external one before a call leaves your infrastructure:

/** At the edge: replace the internal id with the external one before a call leaves your infrastructure. */
fun toExternal(headers: Map<String, String>): Map<String, String> {
val internal = ServiceId.parse(headers.getValue("caller-id"))
return headers + ("caller-id" to internal.externalId)
}

Attach tags​

A tag is either a bare value or a key and value. Tag.parse reads either form:

val tags = listOf(Tag.Basic("retry"), Tag.parse("region=us-east-1"))
val tagged = ServiceId.job("acme", "accounts.signup").with(inst = null, tags = tags)

println(tagged.tags) // [Basic(value=retry), Keyed(key=region, value=us-east-1)]

Set ownership and criticality​

Describe the service with team, criticality, about and uri. None of them is part of an identifier:

val worker =
ServiceId.of(
origin = "acme",
scope = "accounts.signup",
kind = Kind.Worker,
env = "pro",
version = "1.0.2",
about = "Sends the welcome email after signup",
uri = "worker-7.acme.internal",
criticality = Criticality.High,
team = "payments-platform",
)

println(worker.criticality) // High
println(worker.team) // payments-platform
println(worker.provenance) // Declared

Handle a bad id​

parse throws IllegalArgumentException, and Error in TypeScript, with the reason. Catch it where you read the header:

try {
ServiceId.parse("acme:accounts.signup")
} catch (e: IllegalArgumentException) {
println(e.message) // expected 6 segments (origin:scope:kind:env:version:instance), got 2: ...
}

Use it from TypeScript​

The TypeScript port has the same model, with a few differences in shape:

  1. ServiceId.of takes one options object and not named arguments.
  2. Kind is an object with a matching union type, and Tag carries a variant field to narrow on, since TypeScript has no sealed classes.
  3. Equality is the equals method, and compares privateId as in Kotlin.

Every example above has a TypeScript tab.

Reference​

Lookup tables. The ideas behind them are in Explanation.

Accessors​

Values for ServiceId.api("acme", "accounts.signup", "qat") at version 1.4.2:

AccessorAddsExample
pathorigin, scopeacme:accounts.signup
namekindacme:accounts.signup:api
fullNameenvacme:accounts.signup:api:qat
installversionacme:accounts.signup:api:qat:1.4.2
privateIdinstanceacme:accounts.signup:api:qat:1.4.2:4a3b300b-d0ac-4776-8a9c-31aa75e412b3
externalIdalias for pathacme:accounts.signup

Every segment except instance is lowercased.

Kind values​

KindFor
AppA runnable application. Also mobile and desktop apps.
CLIA command line tool.
WebA browser frontend.
APIAn HTTP service.
BotA bot that is not an AI agent.
JobScheduled or one off work.
WorkerA queue or stream consumer, or a worker in a pool.
ServiceA deployable service that doesn't fit another kind.
GatewayAn edge or routing service in front of others: an API gateway or reverse proxy.
FunctionA serverless function: short lived and triggered per event or request.
AgentAn AI agent: software that acts on its own judgment.
TestA test identity.

Criticality values​

ValueMeaning
UnspecifiedNo criticality declared. The default.
LowLow impact if it fails.
MediumMedium impact if it fails.
HighHigh impact if it fails.
CriticalCritical impact if it fails.

Tag forms​

FormBuilt withraw
Tag.BasicTag.Basic("retry")retry
Tag.KeyedTag.Keyed("region", "us-east-1")region=us-east-1
Either, from a stringTag.parse("region=us-east-1")Splits on the first =

Fields and defaults​

The options of ServiceId.of:

FieldDefaultNormalized
originrequiredyes
scoperequiredyes
kindrequiredno
envdevyes
aboutemptyno
versionlatestyes
instancerandom UUIDno, validated
tagsnoneno
urinoneno
criticalityUnspecifiedno
teamemptyno
provenanceDeclared, not an optionno

OpenTelemetry mapping​

How each field lines up with an OpenTelemetry attribute. The origin and scope rows are approximate.

ServiceIdOpenTelemetryFit
scopeservice.nameApproximate. scope is a dotted hierarchy.
originservice.namespaceApproximate. origin is the owning organization.
instanceservice.instance.idSame idea.
versionservice.versionSame.
envdeployment.environment.nameSame value. OpenTelemetry keeps it out of a service's identity.
criticalityservice.criticalitySame idea. OpenTelemetry writes the values in lowercase.
teamnoneServiceId only. Kiit specific: the team that owns the service, such as a Slack team or a distribution list.
kind, about, tags, uri, provenancenoneServiceId only.

Source: OpenTelemetry service attributes.

FAQ​

Common questions about the design, alternatives, adoption and maturity.

Why​

QuestionAnswer
Why not a string constant per subsystem?A string has no shared shape and no parse, and it is easy to leak. This module exists to replace it with one identity that every subsystem can use.
When is this not needed?For outgoing requests to public or external services that are not owned or managed by your team and company. For these, do NOT use privateId, you can use externalId but this is also optional.

Alternatives​

QuestionAnswer
How is this different from OpenTelemetry resource attributes?A resource describes the producer of telemetry. A ServiceId travels in requests and is parsed and compared. Identity, shape and dependencies also differ. See OpenTelemetry.
Can I send these to OpenTelemetry?The service fields map onto resource attributes. The conversion belongs in a separate telemetry adapter, not in this module. See OpenTelemetry mapping.

API​

QuestionAnswer
Why is privateId unsafe to expose, and what do I send instead?It carries the exact version, environment and instance. Send externalId, which carries only origin and scope. See External and private ids.
Why is equality only on privateId?It is the value a server sees in a caller-id header. See Construction.
Can I implement IServiceId myself?Yes, but you get none of the accessors, which live only on ServiceId. See Contract and implementation.
Why is env a string and not an enum?So the library doesn't enforce how you name environments.
Why is Kind a closed set, and which kinds were left out?See Terms.

Adoption​

QuestionAnswer
Can I adopt it in one subsystem first?Yes, start with just adding it for requests to your own internal services as a header.

AI​

QuestionAnswer
How does this help AI tooling?A structured origin:scope:kind id lets a tool tie the caller-id on a request to a git repo and a service when debugging it. Kind.Agent marks an AI agent as the caller.

Maturity​

QuestionAnswer
Is it used in production?Yes, for years, as the original kiit component this was extracted from.