Skip to content

Commit 76252a9

Browse files
cyntwang99claude
andauthored
feat(tracing): add opt-in commit SHA stamping for SGP spans (#505)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 0fa93b6 commit 76252a9

6 files changed

Lines changed: 311 additions & 2 deletions

File tree

src/agentex/lib/adk/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131

3232
# Data-source refs for lineage (SGP-6513); implementation lives in core.tracing
3333
from agentex.lib.core.tracing import lineage
34+
35+
# Opt-in commit-SHA stamping (AGX1-969); implementation in core.tracing
36+
from agentex.lib.core.tracing import code_revision
3437
from agentex.lib.core.tracing.lineage import DataSourceRef, data_sources
3538

3639
# Unified harness surface (AGX1-375)
@@ -73,6 +76,7 @@
7376
"TurnSpan",
7477
# Lineage data-source refs (SGP-6513)
7578
"lineage",
79+
"code_revision",
7680
"DataSourceRef",
7781
"data_sources",
7882
# Checkpointing / LangGraph
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Opt-in stamping of the agent's source commit onto its spans.
2+
3+
Nothing is stamped until the agent calls :func:`enable`, mirroring the
4+
``lineage`` registry next door: a process-wide switch the agent sets once at
5+
import, rather than automatic behaviour every agent inherits. When enabled the
6+
resolved commit lands in span data under ``__commit_sha__`` and is searchable in
7+
the SGP Traces UI as ``__commit_sha__:<sha>``.
8+
9+
This is deliberately separate from ``__agent_version__``, which is automatic and
10+
carries the deployed image tag verbatim ("image tag or git sha"). That tag is a
11+
real commit on some build paths but an ``<image-name>-<sha>`` composite (AWS
12+
ECR), ``latest``, or a hand-passed tag on others -- so a field named for a commit
13+
must not simply mirror it. Values that are not git object names are refused, and
14+
a field named ``__commit_sha__`` therefore only ever holds one.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import os
20+
import re
21+
22+
from agentex.lib.utils.logging import make_logger
23+
24+
__all__ = ("COMMIT_SHA_KEY", "enable", "disable", "is_enabled", "commit_sha")
25+
26+
logger = make_logger(__name__)
27+
28+
COMMIT_SHA_KEY = "__commit_sha__"
29+
30+
# A git object name: 40 hex for SHA-1, 64 for SHA-256, or an abbreviation down to
31+
# git's own 7-character minimum.
32+
_GIT_SHA_RE = re.compile(r"[0-9a-fA-F]{7,64}")
33+
34+
_COMMIT_SHA_ENV = "AGENT_COMMIT_SHA"
35+
# Fallback only: automatic, and only usable when it happens to be SHA-shaped.
36+
_AGENT_VERSION_ENV = "AGENT_VERSION"
37+
38+
# Resolved once at enable() rather than per span: the value is fixed for the
39+
# life of the process, and resolving eagerly means a bad value is reported at
40+
# startup instead of silently producing unstamped spans.
41+
_commit_sha: str | None = None
42+
43+
44+
def enable(commit_sha: str | None = None) -> None:
45+
"""Opt this process in to stamping ``__commit_sha__`` onto every span.
46+
47+
Value precedence: the explicit ``commit_sha`` argument, else
48+
``AGENT_COMMIT_SHA``, else ``AGENT_VERSION`` when the deployment happened to
49+
set it to a bare commit SHA. A value that is not a git object name is
50+
refused with a warning and leaves stamping off -- better an absent field
51+
than one named for a commit that holds an image tag.
52+
"""
53+
global _commit_sha
54+
55+
for value, source in (
56+
(commit_sha, "the commit_sha argument"),
57+
(os.environ.get(_COMMIT_SHA_ENV), _COMMIT_SHA_ENV),
58+
(os.environ.get(_AGENT_VERSION_ENV), _AGENT_VERSION_ENV),
59+
):
60+
candidate = (value or "").strip()
61+
if not candidate:
62+
continue
63+
if _GIT_SHA_RE.fullmatch(candidate):
64+
_commit_sha = candidate
65+
logger.info("code revision stamping enabled from %s", source)
66+
return
67+
# An explicit argument or AGENT_COMMIT_SHA is a direct statement of
68+
# intent, so a bad value there is worth surfacing. AGENT_VERSION is only
69+
# a fallback and is expected to be a non-SHA tag much of the time, so
70+
# falling through it quietly is correct, not a silent failure.
71+
if source != _AGENT_VERSION_ENV:
72+
logger.warning(
73+
"%s=%r is not a git commit SHA; __commit_sha__ will not be stamped.",
74+
source,
75+
candidate,
76+
)
77+
_commit_sha = None
78+
return
79+
80+
_commit_sha = None
81+
logger.warning(
82+
"code revision stamping was enabled but no commit SHA was found "
83+
"(checked the commit_sha argument, %s, and %s); __commit_sha__ will not "
84+
"be stamped. Set %s in the agent's environment -- e.g. bake it at build "
85+
"time with a Dockerfile ARG/ENV.",
86+
_COMMIT_SHA_ENV,
87+
_AGENT_VERSION_ENV,
88+
_COMMIT_SHA_ENV,
89+
)
90+
91+
92+
def disable() -> None:
93+
"""Turn stamping back off (also used for test isolation)."""
94+
global _commit_sha
95+
_commit_sha = None
96+
97+
98+
def is_enabled() -> bool:
99+
"""Whether a commit SHA resolved and will be stamped."""
100+
return _commit_sha is not None
101+
102+
103+
def commit_sha() -> str | None:
104+
"""The resolved commit SHA, or ``None`` when stamping is not enabled."""
105+
return _commit_sha

src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
import os
44
import asyncio
55
import weakref
6-
from typing import cast, override
6+
from typing import Any, cast, override
77

88
import scale_gp_beta.lib.tracing as tracing
99
from scale_gp_beta import SGPClient, AsyncSGPClient
1010
from scale_gp_beta.lib.tracing import create_span, flush_queue
1111
from scale_gp_beta.lib.tracing.span import Span as SGPSpan
1212

1313
from agentex.types.span import Span
14+
from agentex.lib.core.tracing import code_revision
1415
from agentex.lib.types.tracing import SGPTracingProcessorConfig
1516
from agentex.lib.utils.logging import make_logger
1617
from agentex.lib.core.observability import tracing_metrics_recording as _metrics
@@ -69,6 +70,29 @@ def _add_source_to_span(span: Span, env_vars: EnvironmentVariables) -> None:
6970
span.data["__agent_version__"] = env_vars.AGENT_VERSION
7071

7172

73+
def _sgp_metadata(span: Span) -> Any:
74+
"""Metadata for the SGP write: ``span.data`` plus the opt-in commit SHA.
75+
76+
Returns a COPY rather than mutating ``span``. ``trace.py`` hands the same
77+
Span instance to every registered processor, so anything written onto
78+
``span.data`` here would also be serialized by the Agentex processor and
79+
show up in caller-visible span data. ``__commit_sha__`` is opt-in and
80+
SGP-scoped, so it must not leak that way.
81+
82+
(The ``__source__`` / ``__agent_*`` keys set by ``_add_source_to_span`` do
83+
leak like that today. Left as-is: changing five long-shipped fields is not
84+
this change's business.)
85+
"""
86+
commit_sha = code_revision.commit_sha()
87+
if commit_sha is None:
88+
return span.data
89+
if isinstance(span.data, dict):
90+
return {**span.data, code_revision.COMMIT_SHA_KEY: commit_sha}
91+
# List-shaped data is an accepted `data` shape and has nowhere to put a
92+
# metadata key; leave it untouched rather than dropping the caller's data.
93+
return span.data
94+
95+
7296
def _build_sgp_span(span: Span, env_vars: EnvironmentVariables) -> SGPSpan:
7397
"""Build an SGPSpan from an agentex Span. Idempotent on span_id at the SGP backend."""
7498
_add_source_to_span(span, env_vars)
@@ -82,7 +106,7 @@ def _build_sgp_span(span: Span, env_vars: EnvironmentVariables) -> SGPSpan:
82106
trace_id=span.trace_id,
83107
input=span.input,
84108
output=span.output,
85-
metadata=span.data,
109+
metadata=_sgp_metadata(span),
86110
),
87111
)
88112
sgp_span.start_time = span.start_time.isoformat() # type: ignore[union-attr]

src/agentex/lib/environment_variables.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class EnvVarKeys(str, Enum):
2525
AGENT_DESCRIPTION = "AGENT_DESCRIPTION"
2626
AGENT_ID = "AGENT_ID"
2727
AGENT_VERSION = "AGENT_VERSION"
28+
AGENT_COMMIT_SHA = "AGENT_COMMIT_SHA"
2829
AGENT_API_KEY = "AGENT_API_KEY"
2930
# ACP Configuration
3031
ACP_URL = "ACP_URL"
@@ -67,6 +68,12 @@ class EnvironmentVariables(BaseModel):
6768
AGENT_ID: str | None = None
6869
# Build/version discriminator (image tag or git sha), set by the deployment
6970
AGENT_VERSION: str | None = None
71+
# The agent's source commit, baked into the image or set by the deployment.
72+
# Unlike AGENT_VERSION this is expected to be a git SHA and nothing else, and
73+
# it is OPT-IN: nothing is stamped unless the agent calls
74+
# `adk.code_revision.enable()`, which also refuses a value that is not a git
75+
# object name. See agentex.lib.core.tracing.code_revision.
76+
AGENT_COMMIT_SHA: str | None = None
7077
AGENT_API_KEY: str | None = None
7178
ACP_TYPE: str | None = "async"
7279
AGENT_INPUT_TYPE: str | None = None

tests/lib/core/tracing/processors/test_sgp_tracing_processor.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,66 @@ def test_agent_identity_and_version_stamped_into_span_data(self):
5454
"__agent_version__": "sha-abc123",
5555
}
5656

57+
SHA = "b362b171a9c4e1f09d8e7a6b5c4d3e2f1a0b9c8d"
58+
59+
def test_commit_sha_is_not_stamped_without_opt_in(self, monkeypatch):
60+
"""Upgrading the SDK must not start emitting __commit_sha__ on its own,
61+
even when the environment carries a perfectly good SHA."""
62+
from agentex.lib.core.tracing import code_revision
63+
from agentex.lib.core.tracing.processors.sgp_tracing_processor import _sgp_metadata
64+
65+
monkeypatch.setenv("AGENT_COMMIT_SHA", self.SHA)
66+
code_revision.disable()
67+
68+
span = _make_span(); span.data = {}
69+
assert "__commit_sha__" not in (_sgp_metadata(span) or {})
70+
71+
def test_commit_sha_is_stamped_after_opt_in(self, monkeypatch):
72+
from agentex.lib.core.tracing import code_revision
73+
from agentex.lib.core.tracing.processors.sgp_tracing_processor import _sgp_metadata
74+
75+
monkeypatch.setenv("AGENT_COMMIT_SHA", self.SHA)
76+
code_revision.enable()
77+
try:
78+
span = _make_span(); span.data = {"caller": "kept"}
79+
metadata = _sgp_metadata(span)
80+
assert metadata["__commit_sha__"] == self.SHA
81+
assert metadata["caller"] == "kept"
82+
finally:
83+
code_revision.disable()
84+
85+
def test_commit_sha_does_not_leak_onto_the_shared_span(self, monkeypatch):
86+
"""trace.py hands ONE Span to every processor. If the commit SHA were
87+
written onto span.data, a co-registered Agentex processor would
88+
serialize it too, and it would surface in caller-visible span data."""
89+
from agentex.lib.core.tracing import code_revision
90+
from agentex.lib.core.tracing.processors.sgp_tracing_processor import _sgp_metadata
91+
from agentex.lib.core.tracing.processors.agentex_tracing_processor import _create_kwargs
92+
93+
monkeypatch.setenv("AGENT_COMMIT_SHA", self.SHA)
94+
code_revision.enable()
95+
try:
96+
span = _make_span(); span.data = {}
97+
assert _sgp_metadata(span)["__commit_sha__"] == self.SHA # SGP sees it
98+
assert "__commit_sha__" not in span.data # the span does not
99+
assert "__commit_sha__" not in (_create_kwargs(span)["data"] or {})
100+
finally:
101+
code_revision.disable()
102+
103+
def test_list_shaped_data_is_left_alone(self, monkeypatch):
104+
"""`data` may be a list of dicts; there is nowhere to put a metadata key,
105+
and dropping the caller's data would be worse than omitting the field."""
106+
from agentex.lib.core.tracing import code_revision
107+
from agentex.lib.core.tracing.processors.sgp_tracing_processor import _sgp_metadata
108+
109+
monkeypatch.setenv("AGENT_COMMIT_SHA", self.SHA)
110+
code_revision.enable()
111+
try:
112+
span = _make_span(); span.data = [{"a": 1}]
113+
assert _sgp_metadata(span) == [{"a": 1}]
114+
finally:
115+
code_revision.disable()
116+
57117
def test_unset_identity_fields_are_omitted(self):
58118
from agentex.lib.core.tracing.processors.sgp_tracing_processor import _add_source_to_span
59119

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Opt-in commit-SHA stamping.
2+
3+
The contract that matters: an agent that does not call ``enable()`` gets nothing,
4+
so upgrading the SDK never starts emitting this field on its own.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import pytest
10+
11+
from agentex.lib.core.tracing import code_revision
12+
13+
SHA = "b362b171a9c4e1f09d8e7a6b5c4d3e2f1a0b9c8d"
14+
15+
16+
@pytest.fixture(autouse=True)
17+
def _reset():
18+
"""State is process-wide (like the lineage registry), so isolate each test."""
19+
code_revision.disable()
20+
yield
21+
code_revision.disable()
22+
23+
24+
class TestOptIn:
25+
def test_disabled_by_default(self, monkeypatch):
26+
"""Even with the env fully populated, nothing resolves until enable()."""
27+
monkeypatch.setenv("AGENT_COMMIT_SHA", SHA)
28+
monkeypatch.setenv("AGENT_VERSION", SHA)
29+
assert code_revision.commit_sha() is None
30+
assert code_revision.is_enabled() is False
31+
32+
def test_enable_reads_agent_commit_sha(self, monkeypatch):
33+
monkeypatch.setenv("AGENT_COMMIT_SHA", SHA)
34+
code_revision.enable()
35+
assert code_revision.commit_sha() == SHA
36+
assert code_revision.is_enabled() is True
37+
38+
def test_explicit_argument_wins(self, monkeypatch):
39+
monkeypatch.setenv("AGENT_COMMIT_SHA", SHA)
40+
code_revision.enable("7f3a91c2")
41+
assert code_revision.commit_sha() == "7f3a91c2"
42+
43+
def test_disable_turns_it_back_off(self, monkeypatch):
44+
monkeypatch.setenv("AGENT_COMMIT_SHA", SHA)
45+
code_revision.enable()
46+
code_revision.disable()
47+
assert code_revision.commit_sha() is None
48+
49+
50+
class TestValueIsAlwaysACommit:
51+
"""A field named for a commit must never hold an image tag."""
52+
53+
@pytest.mark.parametrize(
54+
"value",
55+
[
56+
"latest",
57+
"v1.2.3",
58+
"0.2.4-v4",
59+
"rocket_mock_agent-b362b171a9c4e1f09d8e7a6b5c4d3e2f1a0b9c8d", # AWS ECR composite
60+
"abc", # shorter than git's 7-char minimum
61+
"z" * 40, # right length, not hex
62+
],
63+
)
64+
def test_non_sha_is_refused(self, monkeypatch, value):
65+
monkeypatch.setenv("AGENT_COMMIT_SHA", value)
66+
code_revision.enable()
67+
assert code_revision.commit_sha() is None
68+
69+
@pytest.mark.parametrize("value", [SHA, SHA.upper(), "b362b17", "a" * 64])
70+
def test_git_object_names_are_accepted(self, monkeypatch, value):
71+
monkeypatch.setenv("AGENT_COMMIT_SHA", value)
72+
code_revision.enable()
73+
assert code_revision.commit_sha() == value
74+
75+
def test_whitespace_only_is_refused(self, monkeypatch):
76+
monkeypatch.setenv("AGENT_COMMIT_SHA", " ")
77+
code_revision.enable()
78+
assert code_revision.commit_sha() is None
79+
80+
def test_enable_with_nothing_available_is_a_no_op(self, monkeypatch):
81+
monkeypatch.delenv("AGENT_COMMIT_SHA", raising=False)
82+
monkeypatch.delenv("AGENT_VERSION", raising=False)
83+
code_revision.enable()
84+
assert code_revision.commit_sha() is None
85+
86+
87+
class TestAgentVersionFallback:
88+
def test_falls_back_to_agent_version_when_sha_shaped(self, monkeypatch):
89+
"""A platform deploy already sets AGENT_VERSION; on GCP/Azure it is a
90+
bare SHA, so an opting-in agent needs no extra plumbing."""
91+
monkeypatch.delenv("AGENT_COMMIT_SHA", raising=False)
92+
monkeypatch.setenv("AGENT_VERSION", SHA)
93+
code_revision.enable()
94+
assert code_revision.commit_sha() == SHA
95+
96+
def test_does_not_fall_back_to_a_non_sha_agent_version(self, monkeypatch):
97+
"""AGENT_VERSION is 'latest' or an AWS composite much of the time."""
98+
monkeypatch.delenv("AGENT_COMMIT_SHA", raising=False)
99+
monkeypatch.setenv("AGENT_VERSION", "latest")
100+
code_revision.enable()
101+
assert code_revision.commit_sha() is None
102+
103+
def test_bad_explicit_value_does_not_fall_through(self, monkeypatch):
104+
"""An explicit AGENT_COMMIT_SHA is a statement of intent: if it is wrong,
105+
say so rather than silently substituting the image tag."""
106+
monkeypatch.setenv("AGENT_COMMIT_SHA", "not-a-sha")
107+
monkeypatch.setenv("AGENT_VERSION", SHA)
108+
code_revision.enable()
109+
assert code_revision.commit_sha() is None

0 commit comments

Comments
 (0)