ADR-177: Caller-source attribution on the options path 

Status

Accepted

Date

2026-08-18

Authors

Netresearch DTT GmbH

Context 

A telemetry row names what ran (operation, provider, model, configuration) and who was logged in (be_user) — but not which piece of software called. Every consumer-facing entry point looks the same in the table: a wizard task, a scheduler run and a downstream extension all appear as the operation they used.

The compatibility layer (nr-llm-compat) makes this gap concrete: it reroutes third-party AI extensions (ai_seo_helper, ns_t3ai, ai_filemetadata, …) through nr-llm at runtime, and its design wants every intercepted request attributable to its origin — so telemetry doubles as an AI inventory of the installation (issue #816). Today it has no channel to say who it is: complete() and completeJson() accept only an options object, and nothing that reaches TelemetryMiddleware carries a caller identity.

Two channels exist and both are wrong for this. The $metadata array on the *WithConfiguration() methods is positional plumbing the feature services do not expose, and inventing a parallel entry point per feature service would multiply the public surface. A global "current caller" service would be state smuggled past the call, breaking for nested calls (a tool loop calling on behalf of another caller).

Decision 

The caller identity travels on the options object — the same channel the idempotency key already uses, with the same wire-leak rule.

  1. AbstractOptions gains `withCallerSource(string $extension, string $operation = ''): static` plus the two getters. Like $idempotencyKey, the fields are never part of toArray(): they are call metadata, not provider options, and must not reach the provider wire.
  2. CallMetadataFactory::callerSource(AbstractOptions $options) maps them into the pipeline metadata (keys sourceExtension / sourceOperation, published as constants), and every consumer-facing entry point in LlmServiceManager that already sums budget() + idempotency() metadata adds this term.
  3. TelemetryMiddleware persists them: tx_nrllm_telemetry gains source_extension and source_operation (varchar(64), '' default), threaded through TelemetryRecord. An unannotated call writes '' — indistinguishable from today's rows, so nothing changes for existing consumers.

The identity is two short strings, not a payload: the extension key of the calling software and the operation inside it (e.g. ai_seo_helper / requestAi). Naming stays with the caller; nr-llm records, it does not validate.

Alternatives considered 

  • Metadata parameter on the feature-service interfaces — a new parameter on every @api method (breaking or default-parameter creep) for a value that is cross-cutting, not per-method.
  • A registered "current consumer" context service — ambient state; wrong answer under nesting (skills/tools calling the pipeline while serving another caller), and invisible in the call signature.
  • Deriving the caller from a backtrace — magic, fragile under DI inlining and proxies, and wrong for queued/deferred execution.

Consequences 

  • Downstream consumers annotate with one wither and get per-source telemetry; the compatibility layer tags every bridge call.
  • The channel is metadata-only (two identifiers). Privacy semantics (ADR-064) are untouched: no payload, no new retention class — the purge command already covers the telemetry table.
  • Attribution is honest but unauthenticated: a caller can claim any name. Telemetry is an observability surface, not an access-control one (access control stays with configurations and budgets).
  • Analytics aggregation by source (dashboard widget, module filter) is deliberately not part of this record; it can follow as UI work once rows carry the columns.
  • api-surface.txt grows additively (new AbstractOptions methods).