Skip to content

2.5 Usage Telemetry Contract

Chargeback across teams, frameworks and clouds only works if every producer describes usage the same way. Esmeralda therefore publishes a contract, not an implementation: the esmeralda.usage record, defined by contracts/usage-event.schema.json. Any producer can implement it: the esmeralda Python library, a gateway access log, an OpenTelemetry Collector processor, or code in another language.

The record

One record per model call (llm.call), tool call (tool.call), agent invocation (agent.invocation) or gateway request (gateway.request). Field names follow the OpenTelemetry GenAI semantic conventions wherever they exist.

{
  "schema": "esmeralda.usage",
  "schema_version": 1,
  "event": "llm.call",
  "timestamp": "2026-01-15T14:03:07.412Z",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "source": "workload",
  "workload": {"name": "ai-coe-mortgage-specialist", "team": "ai-coe", "runtime": "agent_engine",
               "framework": "adk", "cloud": "gcp"},
  "caller": {"verified": false,
             "declared": {"project_id": "example-cx-project", "agent_name": "cx_mortgage_orchestrator"}},
  "gen_ai": {"system": "gcp.vertex_ai", "operation.name": "generate_content",
             "request.model": "gemini-flash", "response.model": "gemini-flash-001",
             "usage.input_tokens": 1840, "usage.output_tokens": 312,
             "usage.cached_tokens": 1024, "usage.reasoning_tokens": 96,
             "agent.name": "mortgage_specialist", "conversation.id": "session-julian-sterling-001"},
  "status": "ok",
  "dimensions": {"loan_product": "fixed_30y"},
  "latency_ms": 1432.5
}
Field Meaning
schema, schema_version Record type and integer contract version. Sinks and metrics filter on jsonPayload.schema = "esmeralda.usage", never on a log name
source workload (the agent or service itself) or gateway (a gateway on the hop)
workload Who served the call: name, team, runtime (agent_engine, cloud_run, gke, external), framework, cloud
caller Who called. principal / team count for chargeback only with verified: true (from authentication). declared is caller-supplied context (cost centre, country, calling project and agent)
gen_ai OTel GenAI attributes without the gen_ai. prefix. usage.input_tokens includes cached tokens and usage.output_tokens includes reasoning tokens; usage.cached_tokens and usage.reasoning_tokens are subsets
tool name, server, call_id, status (ok / error), error_type, duration_ms
gateway, http Gateway records: product (apigee, envoy, agent_gateway, direct), proxy name, HTTP method, status code, route
dimensions Business dimensions (strings). Consumers accept a key only if the catalog registers it
trace_id, span_id W3C ids. Gateway and workload records of one request join on trace_id

agent.invocation token counts are a roll-up of the invocation's model calls. Use them for per-invocation budgets and alerts; chargeback sums llm.call (or gateway) records.

One example per event type lives in contracts/examples/.

Versioning

The version is an integer inside each record, not a v1/ module or folder. Additive changes (new optional fields, new enum values) keep schema_version; only a breaking change bumps it. Producers emit only fields the schema defines; anything business-specific goes in dimensions. The full rules are in contracts/README.md.

Secrets stay out

Records never carry credentials. Before a record leaves the process, esmeralda.usage drops every key that looks like a credential or token (access_token, authorization, api_key, the delegated user credential user_auth_token or whatever USER_AUTH_ID names, …) and masks values that look like bearer tokens, OAuth access tokens, JWTs or API keys, including inside error messages. Tool and model errors are recorded by type (TimeoutError), never by message.

The delegated user credential is also masked by value: the ADK plugin passes the invocation's own credential to esmeralda.user_credential.redact, so it is removed even when it has no recognizable shape, and the credential headers listed in ESMERALDA_USER_CREDENTIAL_TARGETS are never emitted as keys. Manual producers can do the same with secrets=[...] on any record_* call.

Producing records

All Python producers go through esmeralda.usage. Records are written as one JSON line on stdout by default, which Cloud Logging stores as jsonPayload on Agent Runtime, Cloud Run and GKE. Set ESMERALDA_USAGE_TRANSPORT=otel (or stdout,otel) to emit them as OpenTelemetry log records instead, for an OTLP collector on another cloud.

workload comes from the environment:

Variable Field Default
ESMERALDA_WORKLOAD_NAME workload.name the agent name, else AGENT_NAME, OTEL_SERVICE_NAME, K_SERVICE
ESMERALDA_TEAM workload.team unset
ESMERALDA_RUNTIME workload.runtime detected: Agent Runtime, Cloud Run, GKE, else external
ESMERALDA_FRAMEWORK workload.framework set by the producer (adk for the plugin)
ESMERALDA_CLOUD workload.cloud gcp

ADK agents

Nothing to do: esmeralda.create_app(root_agent) installs EsmeraldaTelemetryPlugin, whose callbacks emit an llm.call per model response (token usage including cached and reasoning tokens, latency, finish reason), a tool.call per tool call (status, duration, MCP server when known) and an agent.invocation per run (latency and token roll-up). caller.declared comes from the caller context (temp:caller_context state or A2A metadata) with caller.verified: false, because that context is self-declared.

While the Layer 4 sinks, metrics and dashboards still key on them, the plugin also writes the legacy genai_token_consumption and mcp_tool_execution events. Set ESMERALDA_LEGACY_TELEMETRY_EVENTS=false to stop them once your views read esmeralda.usage.

Any OpenTelemetry-instrumented framework

LangGraph, LangChain, CrewAI or custom code instrumented with the OTel GenAI conventions only needs the span adapter on its TracerProvider:

from opentelemetry.sdk.trace import TracerProvider

from esmeralda.telemetry import BaggageSpanProcessor
from esmeralda.usage import UsageSpanProcessor

provider = TracerProvider()
provider.add_span_processor(BaggageSpanProcessor())  # optional: caller.* attributes from baggage
provider.add_span_processor(UsageSpanProcessor(framework="langgraph"))

UsageSpanProcessor maps finished spans by gen_ai.operation.name: generate_content, chat and text_completion become llm.call, execute_tool becomes tool.call, and invoke_agent becomes agent.invocation. Spans without a GenAI operation name are ignored. caller.* span attributes become caller.declared, and esmeralda.dimension.<key> attributes become dimensions.

One producer per process

Use either the ADK plugin or the span adapter in an ADK process, not both: each records the same calls.

Manual API

For code with neither ADK nor OTel instrumentation, call the typed builders:

from esmeralda import usage

usage.record_llm_call(
    request_model="gemini-flash",
    input_tokens=1840,
    output_tokens=312,
    cached_tokens=1024,
    latency_ms=1432.5,
    dimensions={"loan_product": "fixed_30y"},
)
usage.record_tool_call(name="verify_income", server="income-verification", status="ok", duration_ms=218.4)
usage.record_agent_invocation(agent_name="mortgage_specialist", latency_ms=4105.0)

The module functions use a process-wide UsageRecorder; create your own to choose the transport, the workload or default dimensions:

recorder = usage.UsageRecorder(
    usage.OTelLogsTransport(),
    workload=usage.Workload("loan-scoring", team="risk", runtime="external", framework="custom", cloud="aws"),
    dimensions={"country": "us"},
)
recorder.record_llm_call(request_model="gemini-flash", input_tokens=10, output_tokens=2)

Validation

The test suite validates the schema (JSON Schema draft 2020-12), every example, and every record the library produces (manual API, span adapter, and the ADK plugin inside a real ADK runner) against the contract.