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.
/metrics/metrics), loopback- or Local Area Network (LAN)-reachable from the host's Grafana Alloy agent. Do not push metrics; the plane pulls them.hosts/<host>/apps.alloy in the jakemon repository (a jakemon change — the plane's owner makes it):prometheus.scrape "roller" {
targets = [{ __address__ = "127.0.0.1:8080", __metrics_path__ = "/metrics" }]()
forward_to = [prometheus.remote_write.central.receiver]
job_name = "roller"
}
wikantik_, roller_); the plane adds service, instance and host labels at scrape time. Do not put tenant identifiers in labels (the stack is instanced per tenant).wikantik-observability module and jakemon's visibility/exporter.py (TOP_QUERY_PAGE cardinality cap) are the precedents.instance is required here even though feeds do not carry it — one Prometheus holds every deployment at once. See the plane page's Feeds and multiple product instances.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.
{"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.
| Key | Required? | Meaning |
|---|---|---|
ts | required | ISO-8601 timestamp with offset or Z |
level | required | debug / info / warn / error |
service | required | product name, matching the metric prefix (wikantik, roller) |
msg | required | human-readable message |
event | when the line is an event | machine-stable event name, snake_case, product-prefixed if it might collide (page_view, email_bounced) |
request_id | when inside a request | per-hop identifier minted by this service for this request |
correlation_id | when known | end-to-end identifier that travels unchanged across every hop of a flow through the stack |
instance | recommended | deployment 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.
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:
{service="wikantik"} | json | correlation_id="01M09…" parses on read. This is the intended path, and it is why emitting the envelope has value before any plane change lands.stage.json in loki.process promoting a few keys to structured metadata is worth adding once query volume justifies it — a plane work item, not a product one.correlation_id, request_id, or any 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 (service, instance, host, level). This is the single most damaging mistake available in this contract, and it is easy to make while trying to be helpful.correlation_id — the one field with cross-product semanticsA 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:
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.X-Correlation-Id header.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).
event values and extra keys, not in new top-level structure.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.
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.
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 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.
/metrics with a product prefix, bounded cardinality, instance label, registered in apps.alloyts, level, service, msgevent on every behavioral/business line; request_id inside requestscorrelation_id: honor traceparent, then X-Correlation-Id, else mint; propagate both onward; never a Loki label/healthz for new products; Wikantik's is /api/health)bin/simple-agility-conformance.sh exits 0