ADR-052: Usage attribution honours the caller-supplied beUserUid 

Status

Accepted

Date

2026-07-12

Authors

Netresearch DTT GmbH

Context 

Every option object carries withBeUserUid() (BudgetAwareOptionsInterface), and the manager forwards that uid as pipeline metadata (BudgetMiddleware::METADATA_BE_USER_UID), where BudgetMiddleware uses it for per-user budget enforcement. Usage attribution, however, ignored it: UsageTrackerService always read the ambient backend.user context aspect to fill the be_user column.

For backend-module calls the two sources agree. For every caller outside a backend-user request — frontend plugins, Messenger/CLI workers, scheduler tasks — they do not: the aspect resolves to 0, so usage lands in the anonymous bucket even when the caller passed an explicit uid. Downstream extensions worked around this by impersonating a technical backend user for the duration of a call — swapping the backend.user aspect (and restoring it in a finally) purely so the usage row gets the right be_user. nr_ai_search's BackendUserContext::runAs() is such a workaround, wrapped around every RAG chat call. Enforcement and attribution also disagreed with each other: the budget gate charged the option-supplied user while the usage row credited the ambient one.

Decision 

The caller-supplied uid wins; the ambient aspect stays the fallback.

  • UsageTrackerServiceInterface::trackUsage() gains an optional trailing ?int $beUserUid = null parameter. null preserves the previous behaviour (ambient backend.user aspect, 0 when unauthenticated).
  • UsageMiddleware reads BudgetMiddleware::METADATA_BE_USER_UID from the pipeline context — the same key the budget gate reads — and passes it through, so enforcement and attribution can no longer disagree.

Consequences 

  • A consumer that already sets withBeUserUid() gets correct attribution in frontend/CLI contexts with no further wiring; the aspect-swap workaround becomes unnecessary for usage tracking.
  • Backend-module calls are unaffected: they set no option uid, and the ambient fallback resolves the same user as before.
  • UsageTrackerServiceInterface implementers must add the new parameter (semver-minor breaking in the 0.x line, same policy as ToolInterface::getGroup() in 0.15.0). In-repo, UsageTrackerService is the only implementation.
  • The specialized translator path forwards the uid even though it bypasses the middleware pipeline: TranslationService re-attaches the resolved uid to the options array it hands to TranslatorInterface implementations (the beUserUid key — budget fields are deliberately excluded from TranslationOptions::toArray()), and DeepLTranslator / LlmTranslator pass it on to trackUsage(). The key is attribution metadata only; translators never send it to the remote API.
  • The remaining specialized services are ambient-only: WhisperTranscriptionService, TextToSpeechService, DallEImageService and FalImageService accept option shapes without budget fields (TranscriptionOptions, SpeechSynthesisOptions, ImageGenerationOptions, a plain array), so no caller-supplied uid reaches their trackUsage() calls and attribution falls back to the ambient backend.user aspect. Extending those option shapes is deferred until a consumer needs per-user attribution there.