This is the in-contract of the Simple Agility observability plane. It says what a product, internal-facing or external-facing, must emit so that jakemon can capture it and so that the product's own error and behavioural signals come back to it aggregated. Read it before you write a line of logging in a new product. It is deliberately small. For logs it adopts a published standard, Elastic Common Schema (ECS), and requires only a handful of its fields; see Evolution rules below.
Four parts: metrics, logs, events, health. Conformance is machine-checkable. Run bin/simple-agility-conformance.sh (Wikantik repository) against an instance rather than reading the checklist and forming an opinion.
The decisions behind this page, with their reasons, are in the SimpleAgilityDecisionLog. The ECS logging standard is SA-6, and product-instance naming is SA-5. Each team's concrete steps to get there, and their status, are on SimpleAgilityTelemetryAdoption.
/metrics/metrics. It must be reachable on loopback or the Local Area Network (LAN) from the host's Grafana Alloy agent. Do not push metrics; the plane pulls them.hosts/<host>/apps.alloy in the jakemon repository. This is a jakemon change, made by the plane's owner. The target names the product and the product instance:prometheus.scrape "roller" {
targets = [{
__address__ = "127.0.0.1:8080",
__metrics_path__ = "/metrics",
service = "roller",
service_instance = "roller-prod",
}]()
forward_to = [prometheus.remote_write.central.receiver]
job_name = "roller"
}
wikantik_, roller_). The plane attaches these labels; the product emits none of them:| Label | Meaning | Example |
|---|---|---|
service | The product. It matches the metric prefix and the service.name log field. | wikantik |
service_instance | This running copy of the product, named <product>-<env>: lowercase, joined by a hyphen | wiki-prod, wiki-support, wiki-dev, roller-prod |
job | The scrape job, set in apps.alloy. For a product it is the product name. | wikantik |
instance | The scrape target, which is Prometheus's own meaning of the label | host.docker.internal:8080 |
host | The fleet host | docker1 |
deployment | The whole stack deployment: one tenant's jakemon, Wikantik and Roller | jakemon-home |
Do not emit a label named instance, service or service_instance yourself. Prometheus renames a clashing label to exported_<name>. The names are decided where the deployment is decided, so one product image can run as wiki-prod or wiki-support unchanged.
Product short names:
wiki for Wikantikrollercrafter for CrafterCMSstockroomA new product picks one short name when it joins the stack and adds it to the decision log, SA-5.
Do not put tenant identifiers in labels. The stack is instanced per tenant.
Prefer counters and histograms over gauges for request-shaped things.
Keep label cardinality bounded: no user identifiers, no free text and no unbounded paths. Bucket them or keep the top N. The precedents are Wikantik's wikantik-observability module and jakemon's visibility/exporter.py, whose TOP_QUERY_PAGE setting caps cardinality.
Product identity is required on metrics and logs, although feeds do not carry it, because one Prometheus and one Loki hold every instance at once. See the plane page's Feeds and multiple product instances.
Write logs to stdout. The path is container runtime, then Alloy, then Loki. Use no log files, and no product-side log tables for aggregation. Emit one JSON object per line, using Elastic Common Schema
field names. Dotted keys may be flat ("log.level": "info", as log4j2's ECS layout writes them) or nested ("log": {"level": "info"}), because LogQL reads both the same way.
{"@timestamp":"2026-10-12T06:14:22.481Z","log.level":"info","message":"assembled context bundle","ecs.version":"1.2.0","service.name":"wikantik","event.action":"bundle_assembled","trace.id":"4bf92f3577b34da6a3ce929d0e0e4736","transaction.id":"7f3c1a90","log.logger":"com.wikantik.bundle.Assembler","wikantik.query":"hybrid retrieval","wikantik.sections":12,"event.duration":184000000}
The first four fields are required. The rest are either ECS fields the contract defines below, or the service's own additions under its own namespace (wikantik.*), which need no permission from anybody.
| ECS field | Required? | Meaning |
|---|---|---|
@timestamp | required | ISO-8601 timestamp, in UTC (Z) or with an offset |
log.level | required | debug, info, warn or error. Consumers compare it case-insensitively, because log4j2 writes INFO. |
message | required | Human-readable message |
service.name | required | The product name, matching the metric prefix and the plane's service label (wikantik, roller) |
ecs.version | recommended | Whatever the ECS layout emits. It is informational and not checked. |
event.action | when the line is an event | A machine-stable event name in snake_case, product-prefixed if it might collide (page_view, email_bounced). See §3. |
trace.id | when known | The correlation identifier: travels unchanged across every hop of one flow through the stack. See below. |
transaction.id | inside a request | A per-hop identifier, minted by this service for this request |
error.type, error.message, error.stack_trace | on errors | The exception class, its message and the stack trace, as one field rather than continuation lines |
log.logger | optional | The logger name |
Other fields. Use the ECS field where one exists:
http.request.methodurl.pathclient.ipuser_agent.originalevent.duration, in nanosecondsOtherwise put the field under your product's namespace (roller.post_id, wikantik.sections). Never invent a new top-level key, because it could collide with a future ECS field. Extra fields are welcome; they are simply not promised to exist across services.
The instance name is not in the line. The plane attaches service_instance (and host, deployment) to the log stream, exactly as it does for metrics (§1).
The plane does not parse log JSON at ingest today; the only loki.process block in jakemon extracts ollama metrics. The JSON is still useful now:
Querying works now. LogQL's | json parses on read and turns dots into underscores: log.level becomes log_level, trace.id becomes trace_id. So {service="wikantik"} | json | trace_id="4bf92f35…" finds one flow. This is the intended path. Loki also fills its automatic detected_level label from log.level.
Ingest-time extraction is an optimization, not a requirement. A stage.json in loki.process that promotes a few fields to structured metadata is worth adding once query volume justifies it. That is plane work, not product work.
Never promote trace.id, transaction.id, or any other per-request value to a Loki label. Labels are the index. An unbounded label set multiplies stream cardinality and degrades the whole Loki instance for every service, not just yours. Labels stay low-cardinality and structural. The plane's Loki labels are:
host, deployment and job (container for container stdout);container;service and service_instance, once a product is registered;service_name and detected_level.This is the single most damaging mistake available in this contract, and it is easy to make while trying to be helpful.
trace.id: the one field with cross-product semanticsA user hits a front end: Roller, a Wikantik page or a future product.
trace.id on every line for that flow.transaction.id stays per service and per hop.Agents use trace.id in Loki Query Language (LogQL) to reconstruct how one action moved through the stack. The trace is assembled by the plane, not by the product.
Propagation, in order of precedence:
traceparent, take the trace-id from it. This is exactly what ECS and OpenTelemetry mean by trace.id, so adopting OpenTelemetry later is additive rather than a migration.X-Correlation-Id header.traceparent. Any opaque unique string is acceptable.Send both headers onward: traceparent when you received or can construct one, and X-Correlation-Id always. A service that understands only one of them still participates.
ts, level, msg, service, event, request_id, correlation_id) was replaced by the ECS fields above. This was a one-time move, made while no product emitted the old keys; see SA-6 in the decision log. The rule applies from this version on.event.action values and namespaced fields, not in new top-level structure.Java with log4j2: use JsonTemplateLayout (in log4j-layout-template-json) with its bundled EcsLayout.json event template.
service.name through the template's serviceName setting.trace.id and transaction.id in the Mapped Diagnostic Context (MDC) under exactly those names from the request filter. The layout copies MDC entries into the line.This is an appender-configuration change, not an application rewrite.
Other languages: use Elastic's ECS logging library for the language, or any JSON logger configured with the field names above.
Observed in Loki on 2026-10-12:
ecs.version 1.2.0), and | json and detected_level already work on it. Three gaps remain:
application key instead of service.name.requestId should be transaction.id; method, uri, remoteAddr and userAgent should be http.request.method, url.path, client.ip and user_agent.original.trace.id.The exact changes for each repository are on SimpleAgilityTelemetryAdoption; the sequencing is on SimpleAgilityRoadmap. Until each product lands, the plane still captures its lines; what is lost is LogQL precision, not the logs.
Business and behavioral events ("page viewed", "email opened", "bounce received", "agent tool called", "opportunity snoozed") are emitted as structured log lines carrying event.action. Loki is the event store; LogQL aggregates them; the plane can ship an aggregated feed back to the product if the product needs to join on them. There is no event bus, topic, or product-side events table for analytics.
Two carve-outs, both important enough that the plane page states the general rule: a compliance audit log (tamper-evident, queryable by an auditor — Wikantik's /admin/audit) stays in the product, and so does any table that is a functional input to the product's own loop (retrieval_query_log feeding the agent_gap rule). Emitting the event line and keeping the table is correct, not duplication.
Expose an unauthenticated liveness endpoint that returns 200 only when the service can do its job (dependencies reachable), and — if the product has one — a readiness endpoint the deploy tooling can wait on. Alert rules in the plane key on the probe plus the up metric; a product does not evaluate its own alerts.
The path is the product's choice; the contract needs one, not a specific spelling. Wikantik's is /api/health (plus /api/health/structural-index for subsystem readiness) — do not add a second /healthz alongside it. New products should prefer /healthz absent a reason.
bin/simple-agility-conformance.sh --base-url http://localhost:8080 --prefix wikantik \
--log-cmd 'docker logs --tail 200 wikantik'
It checks health, the metrics endpoint and its prefix, metric cardinality, and the log fields on a sample of real lines, and exits non-zero on any failure. Log checks report SKIP when no log source is given, so the first two parts can be used against any instance straight away. The log checks still test the pre-2026-10-12 envelope keys. Moving them to the ECS fields is a Wikantik-repository item on the roadmap.
/metrics with a product prefix and bounded cardinality, registered in apps.alloy with service and service_instance="<product>-<env>". Do not emit those labels or instance yourself.@timestamp, log.level, message and service.nameevent.action on every behavioural or business line; transaction.id inside requests; errors in error.*trace.id:
traceparent, then X-Correlation-Id, otherwise mint one;/healthz for new products (Wikantik's is /api/health)bin/simple-agility-conformance.sh exits 0