Simple Agility Telemetry Contract

This is the in-contract of the Simple Agility observability plane: 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 behavioral signals come back to it aggregated. Read it before writing a line of logging in a new product. It is deliberately small; the design goal is a solid baseline every product can reasonably support, not a rigid schema — 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.

1. Metrics — expose /metrics

prometheus.scrape "roller" {
  targets    = [{ __address__ = "127.0.0.1:8080", __metrics_path__ = "/metrics" }]()
  forward_to = [prometheus.remote_write.central.receiver]
  job_name   = "roller"
}

2. Logs — stdout, one structured line per event

Write logs to stdout (container runtime → Alloy → Loki). No files, no product-side log tables for aggregation. Emit one JavaScript Object Notation (JSON) object per line using the envelope below.

Canonical line

{"ts":"2026-08-18T06:14:22.481Z","level":"info","service":"wikantik","instance":"wiki-prod","msg":"assembled context bundle","event":"bundle_assembled","request_id":"7f3c1a90","correlation_id":"01M09QF2RWTWZ89YPXX0SAHY1J","query":"hybrid retrieval","sections":12,"duration_ms":184}

The first eight keys are the baseline; query, sections and duration_ms are this service's own additions and need no permission from anybody.

The baseline envelope

KeyRequired?Meaning
tsrequiredISO-8601 timestamp with offset or Z
levelrequireddebug / info / warn / error
servicerequiredproduct name, matching the metric prefix (wikantik, roller)
msgrequiredhuman-readable message
eventwhen the line is an eventmachine-stable event name, snake_case, product-prefixed if it might collide (page_view, email_bounced)
request_idwhen inside a requestper-hop identifier minted by this service for this request
correlation_idwhen knownend-to-end identifier that travels unchanged across every hop of a flow through the stack
instancerecommendeddeployment name (wiki-prod, wiki-support) — the plane can also inject it

Everything else — user_hash, page, duration_ms, error.class, whatever the service needs — is open, per-service, additive. Extra keys are welcome; they simply are not promised to exist across services.

Where the JSON is parsed — and the one thing that must never happen

The plane does not parse log JSON at ingest today; the only loki.process block in jakemon extracts ollama metrics. That is fine and does not make the envelope pointless:

correlation_id — the one field with cross-product semantics

A user hits a front-end (Roller, a Wikantik page, a future product): that entry-point service mints a correlation_id and passes it on every downstream call — chained (A → B → C) or orchestrated (A → B, A → C). Every service honors an inbound correlation_id and never re-mints one; it logs it on every line for that flow. request_id stays per-service, per-hop. Agents use correlation_id in Loki Query Language (LogQL) to reconstruct how one action moved through the stack; the plane, not the product, is where that trace is assembled.

Propagation, in precedence order:

  1. Honor W3C Trace Context if present. If the inbound request carries traceparent, take the trace-id from it and use that as correlation_id. This keeps the stack on the standard road and means adopting OpenTelemetry later is additive rather than a migration.
  2. Otherwise honor an inbound X-Correlation-Id header.
  3. Otherwise mint one — any opaque unique string; a Universally Unique Identifier (UUID) or Universally Unique Lexicographically Sortable Identifier (ULID) is fine.

Send both headers onward (traceparent when you received or can construct one, X-Correlation-Id always), so a service that understands only one still participates.

Wikantik already generates a per-request correlation identifier in wikantik-observability; the contract's additions are that it must accept an inbound one and propagate it on outbound calls (Model Context Protocol (MCP) tool calls to other stack members included).

Evolution rules (why this will not lock you in)

  1. The baseline changes only by adding optional keys. A required key is never added later; a key is never renamed; a meaning is never changed.
  2. Per-service extra keys are unrestricted. If several services converge on the same extra key, it may be promoted to the baseline as optional — never the other way round.
  3. Values, not keys, carry variation: put a service-specific vocabulary in event values and extra keys, not in new top-level structure.
  4. Consumers ignore keys they do not recognize.

Getting a Java service onto the envelope

Neither Wikantik nor Roller emits JSON today — every log4j2*.xml in the Wikantik repository uses a plain-text layout, so both are behind the contract rather than exempt from it. The migration is log4j2's JsonTemplateLayout (bundled in log4j-layout-template-json): replace the console appender's PatternLayout with a JsonTemplateLayout whose template emits the baseline keys, sourcing correlation_id and request_id from the Mapped Diagnostic Context (MDC) that the request filter already populates. It is an appender-configuration change, not an application rewrite. Sequenced in SimpleAgilityRoadmap.

Until it lands the plane still captures the lines; what is lost is LogQL precision, not the logs.

3. Events — are log lines, not a separate pipeline

Business and behavioral events ("page viewed", "email opened", "bounce received", "agent tool called", "opportunity snoozed") are emitted as structured log lines with an event key. 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.

4. Health — one endpoint the plane can probe

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.

Conformance

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 prefix, metric cardinality, and the log envelope on a sample of real lines, exiting non-zero on any failure. Log checks report SKIP when no log source is given, so the first two parts are usable against any instance immediately.

Checklist for a new product

See also