Fix: only 'false'/'0' disable ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS (adk-python parity) - #357
Open
AmaadMartin wants to merge 4 commits into
Conversation
added 4 commits
July 30, 2026 20:20
shouldAddRequestResponseToSpans documented an opt-out contract ("false only
when ... explicitly set to 'false' or '0'") but implemented an opt-in one: an
exact, case-sensitive, untrimmed match against 'true'/'1'. Any other value
silently disabled span content capture, including 'TRUE', 'True', ' true',
'true\n' and whitespace-only values.
Normalize the value with .trim().toLowerCase() and invert the comparison so
only 'false' and '0' disable capture. This matches adk-python, which reads the
same variable with os.getenv(NAME, 'true').strip().lower() and tests it against
a falsy set (src/google/adk/telemetry/context.py).
Use ?? rather than || so the default only applies when the variable is absent,
mirroring os.getenv's default semantics.
…vior These two rows were authored against the old case-sensitive, untrimmed parse and assert the behavior the preceding commit intentionally changes. They are updated, not deleted, and every other row in the table is untouched: 'TRUE' redacts -> captures (case-insensitive affirmative) 'not-a-boolean' redacts -> captures (unrecognized resolves to the default) Both now match the documented opt-out contract and adk-python. The table docstring described the old `|| 'true'` default and the case-sensitive comparison, so it is rewritten to state the contract the rows now pin.
…pture gate Extends the existing capture-gate table so every equivalence class of the normalized parse is pinned against all four gated trace functions: case-variant disabling 'FALSE' -> redacts padded disabling ' false ', '0\n' -> redacts case-variant affirmative 'True' -> captures padded affirmative ' true', 'true\n' -> captures whitespace only ' ' -> captures unrecognized 'yes', 'no', 'treu' -> captures 'no' is called out inline: it is not a supported disabling value in either SDK and now resolves to the default, which is a deliberate behavior change for anyone who used it to opt out.
This was referenced Jul 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
Link to an existing issue (if applicable):
N/A — no public issue is tracking this.
Or, if no issue exists, describe the change:
Problem:
shouldAddRequestResponseToSpans()incore/src/telemetry/tracing.tsdocuments an opt-out contract and implements an opt-in one.
The JSDoc promises "
falseonly whenADK_CAPTURE_MESSAGE_CONTENT_IN_SPANSisexplicitly set to
'false'or'0'", but the comparison is an exact,case-sensitive, untrimmed match against two literals. Any value that is not
byte-for-byte
trueor1silently disables span content capture — the oppositeof what the docstring promises.
TRUE,True," true"(a leading space, e.g.from
KEY = truein a.envfile),"true\n"(a heredoc orkubectlvalue)," ", and typos liketreuall turn capture off today.The failure is silent in the worst way: no warning is logged, and the symptom —
spans arriving with
'{}'where content was expected — looks like a telemetrypipeline problem rather than a config typo. The gate feeds six call sites
(
traceToolCall×2,traceMergedToolCalls,traceCallLlm×2,traceSendData),so a mis-parse blanks content everywhere at once.
Solution: normalize the value before comparing, and invert the comparison so
it actually expresses the documented opt-out:
Two statements in one module-private function; no new types, constants, helpers,
or modules. None of the six call sites change.
Cross-language parity
This is the
adk-pythonbehavior, verified field by field againstsrc/google/adk/telemetry/context.pyin this repo's reference checkout ratherthan inferred from the symbol name:
context.py:50_FALSY_ENV_VALUES = frozenset({'0', 'false'})context.py:211os.getenv(ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS, 'true').strip().lower()context.py:213return env_value not in _FALSY_ENV_VALUESParity here is scoped strictly to how the env string is normalized. Python
resolves this knob through a
TelemetryConfigprecedence ladder (admin lock >per-request
RunConfig> env var > default) with an OTel-spec sibling knob;adk-jshas none of that machinery and this PR does not port it.Where local convention and parity could conflict, parity wins: the accepted
values and their polarity are observable across the language boundary, so they
must agree. Nothing process-local (naming, module layout, privacy of the
function) was changed to match Python.
Before/after contract
'true'/'1''false'/'0''TRUE'/'True''FALSE'' true'/'true\n'' false '/'0\n'''(empty)' '(whitespace only)'yes'/'no'/'treu'The
FALSE/' false 'rows agreed with Python before only by accident: the oldcode returned
falsebecause the value matched nothing, not because itrecognized a disabling value.
Migration note (privacy-relevant — please read)
The only supported disabling values are
falseand0. If you disabled capturewith any other value, switch to
falsebefore upgrading.An operator who wrote a non-canonical negative such as
no,off, ordisabledis currently getting capture OFF by accident — the value matches neither
truenor1. After this change they get capture ON, meaning prompt/responsecontent starts flowing onto spans for a deployment that believed it had opted out.
This is nevertheless the correct resolution:
adk-pythonbehaves exactly this waytoday (
'no'→ capture on), so honoringno/offinadk-jswould create anew parity gap while closing another; and the documented contract has always been
false/0, sonowas never a supported disabling value. The alternative —leaving it alone — keeps the docstring lying about
TRUEand whitespace.Unaffected: every deployment that leaves the var unset or sets lowercase
true/1/false/0. That includes anything mirroringadk-python's deploy-timedefault of
'false'(cli_deploy.py).Collision check
gh pr list --repo AmaadMartin/adk-js --state open --limit 100plusgh pr diff --name-onlyon the adjacent candidates. One real overlap:fix/tracing-capture-env-gate-tests— a test-only PR that pinned thecurrent gate behavior in the exact file this PR must change. It does not
contain the production fix, so this is an overlap, not a duplicate. This PR is
stacked on its branch rather than branched from
main, so Test: Pin the ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS tracing gate with hermetic unit tests #308 must mergefirst. Merging this against
maininstead would conflict and would reinstateassertions that contradict the new contract.
vitest-env-stub-hermeticity) and Fix: scrub ADK environment variables from unit test runs #302 (hermetic-unit-test-env) touch envstubbing but not
core/test/telemetry/tracing_test.tsor the tracing gate. Nocollision.
Because #308 pinned behavior this PR intentionally changes, two of its table rows
are retargeted in a dedicated, clearly-labeled commit (
3786b1b6) rather thandeleted:
'TRUE''not-a-boolean'No other row, assertion, fixture, or description from #308 is modified, and no test
is deleted, skipped, or weakened. Both retargeted rows are still proven to fail
against a mutation (see below), so they retain regression signal.
Design notes
getBooleanEnvVar(core/src/utils/env_aware_utils.ts:99)? Itspolarity is the opposite of what this knob needs. It is a default-off helper
(unset →
false), whereasADK_CAPTURE_MESSAGE_CONTENT_IN_SPANSis default-on.Wiring this gate to it — or to its negation — would flip the documented default and
break every existing deployment. It also has four other live callers, so touching it
widens the blast radius of a one-line telemetry fix. Its own missing-
trim()gap isa separate concern and is deliberately not folded in here.
??and not||???only defaults when the variable is absent,mirroring
os.getenv(name, 'true'). The two are observationally identical here(an empty string trims to
'', which is neither'false'nor'0').'true'literal at all? With a deny-list,undefinedwould failboth inequalities on its own, so
?? 'true'is not load-bearing and?.trim().toLowerCase()would be one line shorter. It is kept deliberately: this isa privacy-relevant default-on knob, and an explicit default states that contract at
the point of the read instead of leaving it to emerge from two inequality checks. It
also keeps the shape aligned with the Python reference, which passes the same literal.
FALSY_ENV_VALUESconstant or new utils module (Pythonuses a
frozensetbecause it has several such vars;adk-jshas exactly one), noyes/no/on/offhandling, no warning log for unrecognized values, noif (!process.env)browser guard, no change to the default, and no export — thefunction stays module-private and is reached through the exported
trace*functions.Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.
Tests extend the existing table in
core/test/telemetry/tracing_test.tsrather thanadding a second parallel matrix — that harness already runs every env value against all
four gated trace functions (
traceCallLlm,traceToolCall,traceMergedToolCalls,traceSendData), which also proves the gate is shared rather than per-function. Twelverows were added covering every equivalence class of the normalized parse:
'FALSE'' false ','0\n''True'' true','true\n'' ''yes','no','treu''no'carries an inline comment naming it as the deliberate flip.Proof the tests can fail (three mutations). Coverage alone is not proof, so each new
row was run against mutated source and confirmed to fail. The three mutations fail
disjoint row sets, which is what shows the matrix pins the normalization and the
polarity independently:
|| 'true',=== 'true' || === '1')'TRUE','True',' true','true\n',' ','yes','no','treu','not-a-boolean') × 4 functionsAssertionError: expected '{}' to contain 'test-model'(and'param1'/'test-result'/'hello-trace-data').trim().toLowerCase(), revert to the opt-in comparison'',' ','yes','no','treu','not-a-boolean')AssertionError: expected '{}' to contain 'test-model'.trim().toLowerCase()'FALSE',' false ','0\n')AssertionError: expected '{"param1":"value1"}' to be '{}'Mutation 3 was added because the
'FALSE'/' false '/'0\n'rows pass under bothmutations 1 and 2 (they are "same result, different reason" rows); without it those three
rows would have been unproven. Every added row and both retargeted rows fail under at
least one mutation. Source was restored byte-exact afterwards and re-verified.
Coverage: the production delta is two statements in one function; the matrix exercises
both outcomes of both comparisons plus the default branch — 100% line and branch coverage
of the new code. No
/* v8 ignore */or any other coverage suppression was added, and nothreshold in
vitest.config.tswas lowered.CI is
absenton this PR — validated locally instead..github/workflows/validation.yamldeclares
pull_request: branches: [main], so itsrun-testsjob does not trigger for a PRwhose base is a stacked branch. The only check that runs here is
auto-assign, which is notvalidation. Once #308 merges and this PR is retargeted to
main,run-testswill firenormally.
Commands run on the exact pushed commit (
87597f120a1dc5ef5e5e2c743da80c52e6800608):The whole
unit:coreproject was run (not just the targeted file) specifically to confirmthe env stubs do not leak across suites.
npm run ts:checkexits non-zero both with and without this change:tsc --noEmitreports 314 errors in 48 files on the base branch and 314 errors in 48 files here.
Normalizing and diffing the two error sets shows they are identical — the only difference
is that the 14 pre-existing errors in
tracing_test.tsshift by exactly +52 lines, thenumber of lines this PR adds. Those are the known
core/dist/typesvscore/srcdual-identity errors on pre-existing mock casts, not introduced or worsened here.
Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
npm install && npm run buildat the repo root.ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS=TRUEand once withADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS=' false '.gcp.vertex.agent.llm_request/gcp.vertex.agent.tool_call_argsattributes and the second emits'{}'. Before thischange, both runs emit
'{}'.No integration test is added: the gate has no I/O, no network, and no cross-component
wiring, so an integration test here would exercise the Vitest env stub rather than the
product.
Checklist
[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.