rootmark 0.0.1__tar.gz → 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. rootmark-0.1.0/.gitignore +37 -0
  2. rootmark-0.1.0/PKG-INFO +134 -0
  3. rootmark-0.1.0/README.md +96 -0
  4. rootmark-0.1.0/pyproject.toml +56 -0
  5. rootmark-0.1.0/src/rootmark/__init__.py +234 -0
  6. rootmark-0.1.0/src/rootmark/audit/__init__.py +5 -0
  7. rootmark-0.1.0/src/rootmark/audit/hashing.py +20 -0
  8. rootmark-0.1.0/src/rootmark/config.py +267 -0
  9. rootmark-0.1.0/src/rootmark/core/__init__.py +10 -0
  10. rootmark-0.1.0/src/rootmark/core/emitter.py +514 -0
  11. rootmark-0.1.0/src/rootmark/core/processor.py +383 -0
  12. rootmark-0.1.0/src/rootmark/patches/__init__.py +96 -0
  13. rootmark-0.1.0/src/rootmark/patches/_bedrock_helpers.py +169 -0
  14. rootmark-0.1.0/src/rootmark/patches/agentcore.py +737 -0
  15. rootmark-0.1.0/src/rootmark/patches/boto3_interceptor.py +615 -0
  16. rootmark-0.1.0/src/rootmark/patches/crewai.py +480 -0
  17. rootmark-0.1.0/src/rootmark/patches/langchain.py +272 -0
  18. rootmark-0.1.0/src/rootmark/patches/openai.py +185 -0
  19. rootmark-0.1.0/src/rootmark/patches/strands.py +669 -0
  20. rootmark-0.1.0/src/rootmark/patches/strands_multiagent.py +571 -0
  21. rootmark-0.1.0/src/rootmark/patches/strands_session.py +217 -0
  22. rootmark-0.1.0/src/rootmark/schema/__init__.py +8 -0
  23. rootmark-0.1.0/src/rootmark/schema/trace_event.py +83 -0
  24. rootmark-0.1.0/tests/core/test_emitter_bind_context.py +129 -0
  25. rootmark-0.1.0/tests/core/test_emitter_session.py +105 -0
  26. rootmark-0.1.0/tests/core/test_processor_envelope.py +285 -0
  27. rootmark-0.1.0/tests/integration/conftest.py +59 -0
  28. rootmark-0.1.0/tests/integration/test_strands_e2e.py +290 -0
  29. rootmark-0.1.0/tests/patches/__init__.py +1 -0
  30. rootmark-0.1.0/tests/patches/test_agentcore.py +544 -0
  31. rootmark-0.1.0/tests/patches/test_strands.py +299 -0
  32. rootmark-0.1.0/tests/test_boto3_interceptor.py +1216 -0
  33. rootmark-0.1.0/tests/test_config_override.py +221 -0
  34. rootmark-0.1.0/tests/test_crewai_patch.py +180 -0
  35. rootmark-0.1.0/tests/test_langchain_handler.py +98 -0
  36. rootmark-0.1.0/tests/test_roundtrip_ingestion.py +127 -0
  37. rootmark-0.1.0/tests/test_span_tags.py +201 -0
  38. rootmark-0.1.0/tests/test_strands_agent_live.py +303 -0
  39. rootmark-0.1.0/tests/test_strands_multiagent.py +454 -0
  40. rootmark-0.1.0/tests/test_strands_multiagent_live.py +387 -0
  41. rootmark-0.1.0/tests/test_strands_patch.py +675 -0
  42. rootmark-0.1.0/tests/test_strands_session.py +290 -0
  43. rootmark-0.1.0/tests/test_suppress_payloads.py +185 -0
  44. rootmark-0.1.0/tests/test_trace_and_span.py +383 -0
  45. rootmark-0.0.1/LICENSE +0 -21
  46. rootmark-0.0.1/PKG-INFO +0 -22
  47. rootmark-0.0.1/README.md +0 -9
  48. rootmark-0.0.1/pyproject.toml +0 -20
  49. rootmark-0.0.1/src/rootmark/__init__.py +0 -12
@@ -0,0 +1,37 @@
1
+ venv/
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+ .mypy_cache/
6
+ dist/
7
+ .pytest_cache/
8
+ .coverage
9
+ .tox/
10
+ .ruff_cache/
11
+ .env
12
+
13
+ # MkDocs
14
+ site/
15
+
16
+
17
+ # migration scratch (not part of the project)
18
+ /_to_delete_git_1786975500/
19
+ /_pip-reserve-dist/
20
+
21
+ # SDK spill-over buffers: runtime state, may contain trace payloads
22
+ .rootmark_spillover.jsonl
23
+ *.rootmark_spillover.jsonl
24
+ .rootmark_spillover.jsonl.tmp
25
+
26
+ # local CLI credentials
27
+ .rootmark.env
28
+
29
+ # fixed dev credentials for `make seed-project` (project ids + raw API key)
30
+ .env.seed
31
+
32
+ # scratch / build output
33
+ /artifacts/
34
+ /.tmp_g7_cli/
35
+
36
+ # TS build output
37
+ *.tsbuildinfo
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.5
2
+ Name: rootmark
3
+ Version: 0.1.0
4
+ Summary: Rootmark SDK -- agent instrumentation
5
+ Project-URL: Homepage, https://rootmark.dev
6
+ Project-URL: Documentation, https://rootmark.dev/docs/
7
+ Project-URL: Source, https://github.com/IvanAdactic/rootmark
8
+ Keywords: agents,instrumentation,llm,observability,tracing
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Classifier: Topic :: System :: Monitoring
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.12
19
+ Requires-Dist: httpx>=0.28.0
20
+ Requires-Dist: pydantic>=2.10.0
21
+ Provides-Extra: boto3
22
+ Requires-Dist: boto3>=1.35.0; extra == 'boto3'
23
+ Provides-Extra: crewai
24
+ Requires-Dist: crewai<2,>=0.86; extra == 'crewai'
25
+ Provides-Extra: dev
26
+ Requires-Dist: crewai<2,>=0.86; extra == 'dev'
27
+ Requires-Dist: langchain-core<2,>=0.3; extra == 'dev'
28
+ Requires-Dist: openai<3,>=1.40.0; extra == 'dev'
29
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
30
+ Requires-Dist: pytest>=8.0; extra == 'dev'
31
+ Provides-Extra: langchain
32
+ Requires-Dist: langchain-core<2,>=0.3; extra == 'langchain'
33
+ Provides-Extra: openai
34
+ Requires-Dist: openai<3,>=1.40.0; extra == 'openai'
35
+ Provides-Extra: strands
36
+ Requires-Dist: strands-agents>=1.26.0; extra == 'strands'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # Rootmark SDK
40
+
41
+ Agent instrumentation for [Rootmark](https://rootmark.dev) — observe, verify and
42
+ govern agent runs.
43
+
44
+ The SDK records what your agents actually did: the span tree of a run, the model
45
+ calls inside it, token counts, and the payloads you choose to keep. It sends
46
+ them to a Rootmark collector, which you can self-host.
47
+
48
+ ```bash
49
+ pip install rootmark
50
+ ```
51
+
52
+ Requires Python 3.12 or newer.
53
+
54
+ ## Quickstart
55
+
56
+ ```python
57
+ import rootmark
58
+
59
+ rootmark.init(
60
+ api_key="pk_...", # project API key
61
+ project_id="...", # UUID
62
+ api_key_id="...", # UUID
63
+ api_url="https://rootmark.example.com",
64
+ workflow_name="my-service", # not "default" -- see below
65
+ )
66
+
67
+ rootmark.patch() # instrument supported frameworks in place
68
+ ```
69
+
70
+ That is the whole integration for the common case. `patch()` installs
71
+ instrumentation process-wide, so call it once at startup and before the first
72
+ agent invocation.
73
+
74
+ ## Grouping and labelling runs
75
+
76
+ ```python
77
+ with rootmark.workflow_override("contract-review"):
78
+ with rootmark.bind_context(tenant_id="acme", session_id=session, tags=["surface:chat"]):
79
+ result = agent(prompt)
80
+ ```
81
+
82
+ `bind_context` applies to everything that runs inside it, including tasks the
83
+ agent loop spawns, because the binding follows the async context.
84
+
85
+ `workflow_name` should not be `"default"`: that value lets framework
86
+ instrumentation override the name you bind per run with its own agent id.
87
+
88
+ ## Framework support
89
+
90
+ The base install instruments nothing on its own beyond direct use. Install the
91
+ extra for what you actually run:
92
+
93
+ ```bash
94
+ pip install "rootmark[openai]" # OpenAI 1.x and 2.x
95
+ pip install "rootmark[langchain]" # LangChain callback handler
96
+ pip install "rootmark[crewai]" # CrewAI event listeners
97
+ pip install "rootmark[strands]" # Strands agents / AWS Bedrock
98
+ pip install "rootmark[boto3]" # Bedrock via botocore, no code change
99
+ ```
100
+
101
+ For boto3, a session built by hand rather than the default one needs
102
+ `rootmark.patch_session(session)` — the botocore hooks only reach the default
103
+ session otherwise.
104
+
105
+ ## Delivery
106
+
107
+ Spans are batched on a background thread and flushed on a timer, so nothing
108
+ blocks a request. A failed export is written to a spill file and retried,
109
+ keeping its batch identity so the collector can recognise a replay rather than
110
+ double-count it. Ordering is preserved: a batch that cannot be delivered holds
111
+ back the ones behind it.
112
+
113
+ Point `spill_file` somewhere writable. The default is relative to the working
114
+ directory, which in a container is frequently owned by root while the process
115
+ runs as someone else — and a spill file that cannot be written is buffering you
116
+ silently do not have.
117
+
118
+ Call `rootmark.flush()` before a process exits if you want outstanding spans
119
+ delivered, and `rootmark.shutdown()` to stop the processor.
120
+
121
+ ## Keeping payloads out
122
+
123
+ ```python
124
+ rootmark.init(..., suppress_payloads=True)
125
+ ```
126
+
127
+ Spans are still recorded — timings, token counts, the shape of the run — but
128
+ prompt and completion text is not sent. Use this when the collector sits outside
129
+ the trust boundary that the data does not leave.
130
+
131
+ ## Links
132
+
133
+ - Documentation: <https://rootmark.dev/docs/>
134
+ - Source: <https://github.com/IvanAdactic/rootmark>
@@ -0,0 +1,96 @@
1
+ # Rootmark SDK
2
+
3
+ Agent instrumentation for [Rootmark](https://rootmark.dev) — observe, verify and
4
+ govern agent runs.
5
+
6
+ The SDK records what your agents actually did: the span tree of a run, the model
7
+ calls inside it, token counts, and the payloads you choose to keep. It sends
8
+ them to a Rootmark collector, which you can self-host.
9
+
10
+ ```bash
11
+ pip install rootmark
12
+ ```
13
+
14
+ Requires Python 3.12 or newer.
15
+
16
+ ## Quickstart
17
+
18
+ ```python
19
+ import rootmark
20
+
21
+ rootmark.init(
22
+ api_key="pk_...", # project API key
23
+ project_id="...", # UUID
24
+ api_key_id="...", # UUID
25
+ api_url="https://rootmark.example.com",
26
+ workflow_name="my-service", # not "default" -- see below
27
+ )
28
+
29
+ rootmark.patch() # instrument supported frameworks in place
30
+ ```
31
+
32
+ That is the whole integration for the common case. `patch()` installs
33
+ instrumentation process-wide, so call it once at startup and before the first
34
+ agent invocation.
35
+
36
+ ## Grouping and labelling runs
37
+
38
+ ```python
39
+ with rootmark.workflow_override("contract-review"):
40
+ with rootmark.bind_context(tenant_id="acme", session_id=session, tags=["surface:chat"]):
41
+ result = agent(prompt)
42
+ ```
43
+
44
+ `bind_context` applies to everything that runs inside it, including tasks the
45
+ agent loop spawns, because the binding follows the async context.
46
+
47
+ `workflow_name` should not be `"default"`: that value lets framework
48
+ instrumentation override the name you bind per run with its own agent id.
49
+
50
+ ## Framework support
51
+
52
+ The base install instruments nothing on its own beyond direct use. Install the
53
+ extra for what you actually run:
54
+
55
+ ```bash
56
+ pip install "rootmark[openai]" # OpenAI 1.x and 2.x
57
+ pip install "rootmark[langchain]" # LangChain callback handler
58
+ pip install "rootmark[crewai]" # CrewAI event listeners
59
+ pip install "rootmark[strands]" # Strands agents / AWS Bedrock
60
+ pip install "rootmark[boto3]" # Bedrock via botocore, no code change
61
+ ```
62
+
63
+ For boto3, a session built by hand rather than the default one needs
64
+ `rootmark.patch_session(session)` — the botocore hooks only reach the default
65
+ session otherwise.
66
+
67
+ ## Delivery
68
+
69
+ Spans are batched on a background thread and flushed on a timer, so nothing
70
+ blocks a request. A failed export is written to a spill file and retried,
71
+ keeping its batch identity so the collector can recognise a replay rather than
72
+ double-count it. Ordering is preserved: a batch that cannot be delivered holds
73
+ back the ones behind it.
74
+
75
+ Point `spill_file` somewhere writable. The default is relative to the working
76
+ directory, which in a container is frequently owned by root while the process
77
+ runs as someone else — and a spill file that cannot be written is buffering you
78
+ silently do not have.
79
+
80
+ Call `rootmark.flush()` before a process exits if you want outstanding spans
81
+ delivered, and `rootmark.shutdown()` to stop the processor.
82
+
83
+ ## Keeping payloads out
84
+
85
+ ```python
86
+ rootmark.init(..., suppress_payloads=True)
87
+ ```
88
+
89
+ Spans are still recorded — timings, token counts, the shape of the run — but
90
+ prompt and completion text is not sent. Use this when the collector sits outside
91
+ the trust boundary that the data does not leave.
92
+
93
+ ## Links
94
+
95
+ - Documentation: <https://rootmark.dev/docs/>
96
+ - Source: <https://github.com/IvanAdactic/rootmark>
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "rootmark"
3
+ version = "0.1.0"
4
+ description = "Rootmark SDK -- agent instrumentation"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ # No licence declared for 0.1.0; to be added in a later release.
8
+ keywords = ["agents", "observability", "tracing", "llm", "instrumentation"]
9
+ classifiers = [
10
+ "Development Status :: 4 - Beta",
11
+ "Intended Audience :: Developers",
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Programming Language :: Python :: 3.14",
16
+ "Topic :: Software Development :: Libraries :: Python Modules",
17
+ "Topic :: System :: Monitoring",
18
+ "Typing :: Typed",
19
+ ]
20
+ dependencies = [
21
+ "pydantic>=2.10.0",
22
+ "httpx>=0.28.0",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://rootmark.dev"
27
+ Documentation = "https://rootmark.dev/docs/"
28
+ Source = "https://github.com/IvanAdactic/rootmark"
29
+
30
+ [project.optional-dependencies]
31
+ # OpenAI integration (Group 4); tested with 1.x and 2.x
32
+ openai = ["openai>=1.40.0,<3"]
33
+ # LangChain callback handler (D4.7); BaseCallbackHandler is stable across 0.3.x and 1.x
34
+ langchain = ["langchain-core>=0.3,<2"]
35
+ # CrewAI event listeners (D5.6); BaseEventListener + crewai_event_bus (tested 0.86–1.x)
36
+ crewai = ["crewai>=0.86,<2"]
37
+ # Strands SDK / AWS Bedrock agent patch (D6.1)
38
+ strands = ["strands-agents>=1.26.0"]
39
+ # Boto3 / botocore interceptor for Bedrock zero-code-change path (D6.3)
40
+ boto3 = ["boto3>=1.35.0"]
41
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "openai>=1.40.0,<3", "langchain-core>=0.3,<2", "crewai>=0.86,<2"]
42
+
43
+ [build-system]
44
+ requires = ["hatchling"]
45
+ build-backend = "hatchling.build"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/rootmark"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+ pythonpath = ["src"]
53
+ markers = [
54
+ "integration: needs docker-compose (API, worker, ClickHouse, Postgres)",
55
+ ]
56
+
@@ -0,0 +1,234 @@
1
+ """Rootmark SDK -- agent instrumentation for behavioral testing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import logging
7
+ from collections.abc import Callable
8
+ from contextlib import contextmanager
9
+ from functools import wraps
10
+ from typing import Any, TypeVar
11
+
12
+ from rootmark.config import (
13
+ RootmarkConfig,
14
+ config_override,
15
+ config_override_sync,
16
+ flush,
17
+ get_config,
18
+ get_processor,
19
+ init,
20
+ shutdown,
21
+ )
22
+ from rootmark.core.emitter import (
23
+ SpanContext,
24
+ SpanEmitter,
25
+ add_span_tags,
26
+ bind_context,
27
+ get_ambient_context,
28
+ merge_span_extra,
29
+ reset_span_context,
30
+ workflow_override,
31
+ )
32
+ from rootmark.patches import apply_patches
33
+ from rootmark.schema import RunType, SpanStatus, TokenCount, TraceEvent
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ __version__ = "0.1.0"
38
+
39
+ F = TypeVar("F", bound=Callable[..., Any])
40
+
41
+ _PATCHED = False
42
+
43
+
44
+ def patch() -> None:
45
+ """Activate optional integrations (OpenAI, ...). Idempotent."""
46
+ global _PATCHED
47
+ if _PATCHED:
48
+ return
49
+ _PATCHED = True
50
+ apply_patches()
51
+
52
+
53
+ def fork_context():
54
+ """Copy the current span context for use in a parallel branch.
55
+
56
+ Delegates to :meth:`SpanEmitter.fork_context`. Use when spawning
57
+ threads or executor tasks that should inherit the current trace
58
+ parentage without sharing mutable ContextVar state.
59
+ """
60
+ return SpanEmitter.fork_context()
61
+
62
+
63
+ def patch_session(boto3_session: object) -> None:
64
+ """Register the Bedrock interceptor on a specific ``boto3.Session``.
65
+
66
+ Use this for multi-session applications where different sessions have
67
+ different credentials or regions. Idempotent per session instance.
68
+ """
69
+ from rootmark.patches.boto3_interceptor import patch_session as _ps
70
+
71
+ _ps(boto3_session)
72
+
73
+
74
+ def _safe_serialize(obj: Any, max_len: int = 8000) -> Any:
75
+ try:
76
+ if obj is None or isinstance(obj, (bool, int, float)):
77
+ return obj
78
+ if isinstance(obj, str):
79
+ return obj if len(obj) <= max_len else obj[:max_len] + "...(truncated)"
80
+ r = repr(obj)
81
+ return r if len(r) <= max_len else r[:max_len] + "...(truncated)"
82
+ except Exception:
83
+ return "<unserializable>"
84
+
85
+
86
+ def _fn_inputs(fn: Callable[..., Any], args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]:
87
+ try:
88
+ sig = inspect.signature(fn)
89
+ bound = sig.bind_partial(*args, **kwargs)
90
+ bound.apply_defaults()
91
+ return {k: _safe_serialize(v) for k, v in bound.arguments.items()}
92
+ except Exception:
93
+ return {"args": _safe_serialize(args), "kwargs": _safe_serialize(kwargs)}
94
+
95
+
96
+ def trace(workflow: str | None = None) -> Callable[[F], F]:
97
+ """
98
+ Decorator: one root ``AGENT`` span per function invocation, with optional workflow name.
99
+
100
+ Supports sync and async callables. Clears span context so each call is a new trace.
101
+ """
102
+
103
+ def decorator(fn: F) -> F:
104
+ if inspect.iscoroutinefunction(fn):
105
+
106
+ @wraps(fn)
107
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
108
+ reset_span_context()
109
+ with workflow_override(workflow):
110
+ inputs = _fn_inputs(fn, args, kwargs)
111
+ ctx = SpanEmitter.start_span(RunType.AGENT, fn.__name__, {"inputs": inputs})
112
+ try:
113
+ result = await fn(*args, **kwargs)
114
+ SpanEmitter.end_span(
115
+ ctx,
116
+ {"return": _safe_serialize(result)},
117
+ SpanStatus.SUCCESS,
118
+ )
119
+ return result
120
+ except Exception as e:
121
+ SpanEmitter.end_span(ctx, {}, SpanStatus.ERROR, str(e))
122
+ raise
123
+
124
+ return async_wrapper # type: ignore[return-value]
125
+
126
+ @wraps(fn)
127
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
128
+ reset_span_context()
129
+ with workflow_override(workflow):
130
+ inputs = _fn_inputs(fn, args, kwargs)
131
+ ctx = SpanEmitter.start_span(RunType.AGENT, fn.__name__, {"inputs": inputs})
132
+ try:
133
+ result = fn(*args, **kwargs)
134
+ SpanEmitter.end_span(
135
+ ctx,
136
+ {"return": _safe_serialize(result)},
137
+ SpanStatus.SUCCESS,
138
+ )
139
+ return result
140
+ except Exception as e:
141
+ SpanEmitter.end_span(ctx, {}, SpanStatus.ERROR, str(e))
142
+ raise
143
+
144
+ return sync_wrapper # type: ignore[return-value]
145
+
146
+ return decorator
147
+
148
+
149
+ @contextmanager
150
+ def span(
151
+ name: str,
152
+ *,
153
+ run_type: RunType,
154
+ inputs: dict[str, Any] | None = None,
155
+ tags: list[str] | None = None,
156
+ ):
157
+ """
158
+ Context manager: one child span (``name``, ``run_type``) around a block of code.
159
+
160
+ Example: ``with rootmark.span("search", run_type=RunType.TOOL): ...``
161
+
162
+ ``tags`` are flat labels the trace list can filter on (B2). Prefer them
163
+ over ``extra`` for anything you want to *search by*: tags are a real
164
+ column, while ``extra`` is stored as unindexed JSON text.
165
+ """
166
+ ctx = SpanEmitter.start_span(run_type, name, inputs, tags=tags)
167
+ try:
168
+ yield ctx
169
+ except Exception as e:
170
+ SpanEmitter.end_span(ctx, {}, SpanStatus.ERROR, str(e))
171
+ raise
172
+ SpanEmitter.end_span(ctx, {}, SpanStatus.SUCCESS)
173
+
174
+
175
+ def set_span_tags(tags: list[str]) -> None:
176
+ """Attach filterable tags to the innermost open span before it ends.
177
+
178
+ Companion to :func:`set_span_extra` for the case where the value is a label
179
+ you want to *find traces by* -- an incident id, a customer tier, a release
180
+ channel. Tags land in the ``tags`` column and drive the trace list's tag
181
+ filter; ``extra`` is stored as JSON text and is not indexed.
182
+
183
+ Duplicates are collapsed, so calling this repeatedly is safe.
184
+ """
185
+ try:
186
+ if get_config() is None:
187
+ return
188
+ add_span_tags(list(tags))
189
+ except Exception:
190
+ logger.debug("rootmark: set_span_tags failed", exc_info=True)
191
+
192
+
193
+ def set_span_extra(extra: dict[str, Any]) -> None:
194
+ """Attach additional metadata to the innermost open span before it ends.
195
+
196
+ This is a best-effort helper for injecting application-level metadata
197
+ (e.g., cost calculations, tenant IDs, ``session_id``) into traces.
198
+ Values are merged when the span closes; ``session_id`` is promoted to the
199
+ top-level :class:`TraceEvent` field (not kept in ``extra``).
200
+ """
201
+ try:
202
+ if get_config() is None:
203
+ return
204
+ merge_span_extra(extra)
205
+ except Exception:
206
+ logger.debug("rootmark.set_span_extra: failed to set extra", exc_info=True)
207
+
208
+
209
+ __all__ = [
210
+ "RootmarkConfig",
211
+ "RunType",
212
+ "SpanContext",
213
+ "SpanEmitter",
214
+ "SpanStatus",
215
+ "TokenCount",
216
+ "TraceEvent",
217
+ "bind_context",
218
+ "config_override",
219
+ "config_override_sync",
220
+ "flush",
221
+ "fork_context",
222
+ "get_ambient_context",
223
+ "get_config",
224
+ "get_processor",
225
+ "init",
226
+ "patch",
227
+ "patch_session",
228
+ "set_span_extra",
229
+ "set_span_tags",
230
+ "shutdown",
231
+ "span",
232
+ "trace",
233
+ "workflow_override",
234
+ ]
@@ -0,0 +1,5 @@
1
+ """Audit-layer helpers (hash-chain canonical JSON, etc.)."""
2
+
3
+ from rootmark.audit.hashing import canonical_json, compute_batch_hash
4
+
5
+ __all__ = ["canonical_json", "compute_batch_hash"]
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from typing import Any
6
+
7
+
8
+ def canonical_json(obj: Any) -> bytes:
9
+ return json.dumps(
10
+ obj,
11
+ ensure_ascii=False,
12
+ separators=(",", ":"),
13
+ sort_keys=True,
14
+ default=str,
15
+ ).encode("utf-8")
16
+
17
+
18
+ def compute_batch_hash(events_payload: list[dict[str, Any]]) -> str:
19
+ """SHA-256 hex digest over canonical JSON of the events list."""
20
+ return hashlib.sha256(canonical_json(events_payload)).hexdigest()