Repository navigation
feat: respect OTEL_SERVICE_NAME env var for service.name resource attribute - #781
Conversation
…ribute When tj serve is running and HTTP mode is used, the service.name resource attribute was hardcoded to 'tokenjam', making it impossible for external agents using the SDK to set their own agent name in tj status. This change reads OTEL_SERVICE_NAME from the environment first, falling back to 'tokenjam' if not set — preserving existing behavior while allowing external agents to identify themselves correctly. Fixes: agent always showing as 'tokenjam' in tj status when using patch_anthropic() or other SDK integrations from external projects.
[Medium impact] The PR appears safe to merge; the previous test-isolation issue is fixed.
|
anilmurty
left a comment
There was a problem hiding this comment.
Thanks @ashwmu — the problem you've found is real, and the fallback you're relying on is real too: otlp_parsing.py:154 does use service.name as the agent_id when gen_ai.agent.id is absent. But I think the change needs rework in two places, and there's a design question worth settling before either.
1. Blocking: the bootstrap.py half has no effect
Greptile called this and it checks out. TjHttpExporter.export() builds its OTLP payload from scratch and hardcodes the resource (http_exporter.py:46). It never reads span.resource, so setting the resource on the TracerProvider changes nothing on the wire.
I ran it rather than reading it. Building the resource exactly as _try_http_mode does after this patch, with OTEL_SERVICE_NAME=my-agent, then capturing the POST body:
resource on the provider : my-agent
service.name ON THE WIRE : tokenjam
Your PR body says the bug is "when tj serve is running and HTTP mode is used", so this is the exact path you're targeting. As it stands that half of the diff is dead code. The fix is in http_exporter.py: send the span's own resource attributes instead of a literal.
2. It only covers one of the two env vars
os.environ.get("OTEL_SERVICE_NAME") handles one spelling. OTEL_RESOURCE_ATTRIBUTES=service.name=my-agent is the other standard one, and it still lands on "tokenjam":
OTEL_SERVICE_NAME=my-agent -> my-agent ✓
OTEL_RESOURCE_ATTRIBUTES=service.name=my-agent -> tokenjam ✗
Worth knowing: _build_tj_resource already solves this problem for service.version, and its docstring spells out the pattern. Resource.create() detects both env vars for you; the explicit dict is what overrides them. So the shape that matches the file's own convention is to let the detector run and only fill in a default when nothing was detected:
resource = Resource.create()
if resource.attributes.get("service.name") in (None, "unknown_service"):
resource = resource.merge(Resource({"service.name": "tokenjam"}))I tried it. All four cases come out right, including OTEL_SERVICE_NAME winning over OTEL_RESOURCE_ATTRIBUTES when both are set, which is what the spec requires and what os.environ.get cannot do on its own.
3. The design question
Before we add a second way to name an agent, is this the knob we want?
The supported path today is @watch(agent_id="my-agent"), and patch_anthropic() inherits from it (integrations/anthropic.py:86, "Inherit agent_id from parent span"). Your symptom, every external agent showing as tokenjam, is what happens when patch_*() is called with no @watch() wrapper above it.
So there are two readings. If the agent should be wrapped, the fix is documentation, not code. If a process can legitimately be one agent end to end with no per-request wrapper, that's a real gap, but OTEL_SERVICE_NAME is an odd place to put the answer: in OTel it names the service, and we would be quietly giving it a second meaning. A [sdk] agent_id config key, or an argument on the bootstrap call, would be explicit and discoverable.
There is precedent on your side. otlp_parsing.py:152 says "OpenClaw sets service.name", so we already read it that way for one producer. I'm not against it. I'd just like the decision made deliberately and written down, because right now nothing documents that OTEL_SERVICE_NAME renames your agent, and the next person will hit the same confusion from the other direction.
Which did you run into? If @watch() was impractical in tokenjam-agents, say why and that settles it.
4. No test
Every fix here ships with a test that fails without it. tests/unit/test_provider_attribution.py is the natural home, and _build_tj_resource was pulled out specifically to be unit-testable. A case per env var, plus one asserting the HTTP exporter puts the resolved name on the wire, would have caught both findings above.
CI is green, which is worth noting for the opposite reason: nothing in the suite covers either path.
Happy to take this the rest of the way if you'd rather hand it over. The problem is worth fixing.
|
Adding two things: a plainer summary of what I'm asking for, and something you can't see from this repo that changes the stakes on the design question. The plain versionEvery span that reaches tj has to say which agent it came from. tj looks in two places, in order: a field called The SDK's way to fill in the first one is What the PR misses is that one of the two files you changed has no effect. In HTTP mode the thing that actually sends data to I checked rather than assuming. Building the resource exactly as your patched Your PR description says the bug is "when tj serve is running and HTTP mode is used". That is this path. So the The second thing is smaller. Why the design question matters more than it looksHere's the part you can't see from this repo. In OSS, The OSS install forwards to Cloud through Which means if we ship this as-is, someone who sets This is the argument for an explicit knob instead: a I'm still open to the env var if you hit a case where One more thing worth stealing: |
|
Thanks for the detailed walkthrough. The OTEL_SERVICE_NAME approach was the wrong diagnosis. I agree it shouldn’t be shipped - the Cloud identity risk you described (permanent agent rows, UNIQUE constraint) makes the env var dangerous for anyone using it for its documented OTel meaning. Updated the PR: |
TjHttpExporter.export() was hardcoding service.name='tokenjam' in the OTLP payload it sends to tj serve, ignoring the gen_ai.agent.id set on spans by @watch(). tj falls through to service.name when gen_ai.agent.id is absent, so every external agent appeared as 'tokenjam' in tj status. Fix: group spans by gen_ai.agent.id and derive service.name from that, following the same pattern as cloud_sync.py:571. Falls back to 'tokenjam' when no agent id is present. Adds three tests to test_http_exporter_auth.py covering: - agent_id present -> service.name on the wire matches - agent_id absent -> falls back to 'tokenjam' - mixed agents -> grouped into separate resourceSpans entries
anilmurty
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround @ashwmu. The exporter fix is a real improvement and I want to keep it. But the PR still does not fix the thing it is named after, and the new test now pins the bug in place.
The headline symptom is unchanged
I ran both paths against your branch, with OTEL_SERVICE_NAME=my-agent set:
provider resource says: my-agent
@watch used (agent.id present) service.name=['my-agent'] agent.id on span=True
patch_* only, no @watch (the reported bug) service.name=['tokenjam'] agent.id on span=False
The second row is your PR description, verbatim: "agent always showing as 'tokenjam' in tj status when using patch_anthropic() or other SDK integrations from external projects." It still says tokenjam. OTEL_SERVICE_NAME is set and still ignored, because the resource _try_http_mode builds is still never read by anything.
So the bootstrap.py half of the diff remains dead code, which is what Greptile is still flagging too.
And the first row does nothing either
When gen_ai.agent.id is present, the parser reads it straight off the span attributes (otel/otlp_parsing.py:148) and never consults service.name at all. The fallback on line 154 only fires when agent_id is missing.
So the new grouping is a no-op in both directions: where it changes service.name, nothing reads it; where something would read it, it still says tokenjam.
I am not asking you to revert it. Grouping by agent and carrying a real name is the correct shape, it matches what the Cloud bridge already does in core/cloud_sync.py:571, and it matters the moment these spans reach a receiver that keys on service.name. It is just not the fix.
The test pins the bug
def test_export_falls_back_to_tokenjam_when_no_agent_id() -> None:
"""Spans without gen_ai.agent.id fall back to service.name=tokenjam."""
...
assert attrs["service.name"] == "tokenjam"That asserts the exact behaviour you opened the PR to change. Critical Rule 23 in CLAUDE.md is about this shape: a green suite that enforces the defect, so the next person to do the right thing is told by CI that they broke something.
The repair is to invert it rather than delete it. The case is worth a test; the expected value is wrong. It should assert that a span with no agent_id picks up the configured service name, and fall back to tokenjam only when nothing is configured.
There is also no test anywhere for OTEL_SERVICE_NAME, which is the one behaviour the PR is named after.
What would actually close this
The exporter needs to fall back to the resource, not to a literal:
agent_id = str(attrs.get("gen_ai.agent.id") or "")
if not agent_id:
agent_id = str((span.resource.attributes or {}).get("service.name") or "tokenjam")That makes the bootstrap.py half live, and OTEL_SERVICE_NAME finally reaches the wire. Keep your grouping exactly as it is.
While you are in provider.py: os.environ.get("OTEL_SERVICE_NAME") still misses OTEL_RESOURCE_ATTRIBUTES=service.name=…, and _build_tj_resource's own docstring explains the pattern that handles both, which it already applies to service.version ten lines below your change.
One thing to settle before more code
I raised this last time and it is still open, and it decides how much of the above is worth doing.
The supported way to name an agent is @watch(agent_id=...), which patch_anthropic() inherits from (integrations/anthropic.py:86). Your symptom is what happens when patch_*() runs with no @watch() above it.
So: did you hit a case where @watch() genuinely did not fit in tokenjam-agents? If yes, that is a real gap and I would rather close it with an explicit knob than by overloading an OpenTelemetry variable. One more reason, which you cannot see from this repo: in Cloud, service.name is agent identity, stored under a unique constraint per org. Anyone who sets OTEL_SERVICE_NAME for ordinary OTel reasons would be creating or renaming an agent in their org without knowing it.
If @watch() would have worked, the fix is a docs line and we close this.
Tell me which and I will take it from there, or finish it myself if you would rather hand it over.
The bug was not a missing env-var read. It was an override.
`Resource.create({"service.name": ...})` merges the explicit dict OVER
whatever the detector read from OTEL_SERVICE_NAME and
OTEL_RESOURCE_ATTRIBUTES, so passing the key at all is what made both
variables inert. Reading one of them by hand fixes half the problem: it
leaves OTEL_RESOURCE_ATTRIBUTES=service.name=... ignored, and it cannot
express the spec's precedence when both are set.
So stop passing the key. Let the detector run, and default to "tokenjam"
only when it found nothing -- the same shape `_build_tj_resource` already
used for service.version, and for the same stated reason. All five cases
now behave: unset, either variable alone, both (OTEL_SERVICE_NAME wins),
and the pre-existing service.version path unaffected.
TjHttpExporter now falls back to the span's resource rather than to a
literal. Grouping by gen_ai.agent.id is kept exactly as it was -- it is
the right shape and matches what the Cloud bridge does -- but falling
straight to "tokenjam" meant the resolved name never reached the wire,
which is why the reported symptom survived the first two commits:
patch_* with no @watch, OTEL_SERVICE_NAME=my-agent
before: service.name=tokenjam
after: service.name=my-agent
Identity is now most-specific-first: the span's own agent id, then the
declared service name, then tj's own.
`test_export_falls_back_to_tokenjam_when_no_agent_id` asserted
service.name == "tokenjam" for a span with no agent id, which is exactly
the defect this PR exists to remove -- a green suite enforcing the bug
(Critical Rule 23). Inverted rather than deleted: the case is worth
testing, the expected value was wrong. It now asserts the declared name
is used, with a separate test for the genuinely-undeclared fallback and
one pinning that an explicit agent id still wins.
Each new test was verified to fail against the half of the fix it covers
by reverting that half in turn.
ruff and mypy clean; 6,021 pass. The three summarize failures under
-n auto are pre-existing cross-test leakage, identical on main and green
in isolation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: ashwmu <noreply@github.com>
|
Took this the rest of the way, @ashwmu — pushed The reframe that settles the design questionI kept asking whether we should give
That makes this a plain bug fix, not a new knob, and it removes my objection. Nothing is being overloaded. It also explains why reading one variable by hand only got halfway: The fix is to stop passing the key, let the detector run, and default to The exporterYour grouping by Identity is now most-specific-first: the span's own agent id, then the declared service name, then tj's own. The test
Inverted rather than deleted, per Critical Rule 23. The case was worth testing; the expected value was wrong. It now asserts the declared name is used, with separate tests for the genuinely-undeclared fallback and for an explicit agent id still winning. Each new test was checked against the half of the fix it covers by reverting that half and watching it go red. Still worth doing, separately
Cloud, for context you cannot see from this repo: ruff and mypy clean, 6,021 tests pass. The three Thanks for pushing on this one — the exporter grouping was the right instinct and it stays. |
Greptile caught that `test_export_falls_back_to_tokenjam_when_nothing_is_
declared` never cleared the environment. `_make_span()` builds its resource
with `Resource.create({})`, which reads OTEL_SERVICE_NAME and
OTEL_RESOURCE_ATTRIBUTES, so anyone with either exported saw the test fail
on their own shell rather than on the code. Reproduced:
OTEL_SERVICE_NAME=my-agent pytest tests/unit/test_http_exporter_auth.py
-> 1 failed
Sweeping for the rest of the class found two more, both of which passed on
main and only became ambient-dependent because of the previous commit:
`test_defaults_to_tokenjam_package_version` and
`test_caller_declared_service_version_via_env_wins` each assert
service.name == "tokenjam" while declaring nothing, which is only true in a
clean environment now that the detector is allowed to run.
Fixed with an autouse fixture per module rather than three delenv pairs, so
a test has to opt IN to an env var to be affected by one. The next test
written in these files is safe without its author thinking about it, which
is the half that matters (Critical Rule 47 is this class).
Also corrected a comment that said service.name "isn't overridden" — it is
now, just not by an env string that declares only a version — and hoisted
the mid-file imports in the exporter test so ruff is clean on both files.
CI lints only tokenjam/, so those four pre-existing E402s were never gated.
Verified green under OTEL_SERVICE_NAME set, OTEL_RESOURCE_ATTRIBUTES set,
and a clean env. Full suite 6,021 pass with OTEL_SERVICE_NAME exported; the
three summarize failures are the pre-existing -n auto leakage, identical on
main.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolved. The exporter now falls back to the span's resource, the provider lets the OTel detector resolve service.name (both standard env vars, correct precedence), the test that pinned the symptom is inverted, and the ambient-env dependency Greptile found is fixed with an autouse fixture. CI 6/6, Greptile 5/5 with no actionable issues.
Dismissing my own request rather than converting it to an approval: the last two commits are mine, so the approving review should not also be. Over to @anilmurty.
Problem
TjHttpExporter.export() was hardcoding service.name="tokenjam" in the OTLP payload sent to tj serve, ignoring the gen_ai.agent.id set on spans by @watch(). Since tj falls through to service.name when gen_ai.agent.id is absent on the wire, every external agent appeared as "tokenjam" in tj status.
Root cause
@watch(agent_id="my-agent") was used correctly in tokenjam-agents. The issue was that http_exporter.py was overwriting the agent identity with a hardcoded literal before sending to tj serve.
Fix
Following the pattern in cloud_sync.py:571: group spans by gen_ai.agent.id and derive service.name from that. Falls back to "tokenjam" when no agent id is present. The OTEL_SERVICE_NAME approach from the first commit is dropped - @watch() is the correct and documented path.
Changes
tokenjam/sdk/http_exporter.py- derives service.name from gen_ai.agent.idtests/unit/test_http_exporter_auth.py- 3 new tests covering agent_id present, absent, and mixed agentsRelated issue
No existing issue - discovered while building tokenjam-agents reference implementation.
Checklist
pytest tests/unit/ tests/synthetic/ tests/agents/ tests/integration/)ruff check tokenjam/)mypy tokenjam/)tests/factories.py(not rawNormalizedSpan)@anilmurtyas reviewer (or @-mentioned him above)