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.
- rootmark-0.1.0/.gitignore +37 -0
- rootmark-0.1.0/PKG-INFO +134 -0
- rootmark-0.1.0/README.md +96 -0
- rootmark-0.1.0/pyproject.toml +56 -0
- rootmark-0.1.0/src/rootmark/__init__.py +234 -0
- rootmark-0.1.0/src/rootmark/audit/__init__.py +5 -0
- rootmark-0.1.0/src/rootmark/audit/hashing.py +20 -0
- rootmark-0.1.0/src/rootmark/config.py +267 -0
- rootmark-0.1.0/src/rootmark/core/__init__.py +10 -0
- rootmark-0.1.0/src/rootmark/core/emitter.py +514 -0
- rootmark-0.1.0/src/rootmark/core/processor.py +383 -0
- rootmark-0.1.0/src/rootmark/patches/__init__.py +96 -0
- rootmark-0.1.0/src/rootmark/patches/_bedrock_helpers.py +169 -0
- rootmark-0.1.0/src/rootmark/patches/agentcore.py +737 -0
- rootmark-0.1.0/src/rootmark/patches/boto3_interceptor.py +615 -0
- rootmark-0.1.0/src/rootmark/patches/crewai.py +480 -0
- rootmark-0.1.0/src/rootmark/patches/langchain.py +272 -0
- rootmark-0.1.0/src/rootmark/patches/openai.py +185 -0
- rootmark-0.1.0/src/rootmark/patches/strands.py +669 -0
- rootmark-0.1.0/src/rootmark/patches/strands_multiagent.py +571 -0
- rootmark-0.1.0/src/rootmark/patches/strands_session.py +217 -0
- rootmark-0.1.0/src/rootmark/schema/__init__.py +8 -0
- rootmark-0.1.0/src/rootmark/schema/trace_event.py +83 -0
- rootmark-0.1.0/tests/core/test_emitter_bind_context.py +129 -0
- rootmark-0.1.0/tests/core/test_emitter_session.py +105 -0
- rootmark-0.1.0/tests/core/test_processor_envelope.py +285 -0
- rootmark-0.1.0/tests/integration/conftest.py +59 -0
- rootmark-0.1.0/tests/integration/test_strands_e2e.py +290 -0
- rootmark-0.1.0/tests/patches/__init__.py +1 -0
- rootmark-0.1.0/tests/patches/test_agentcore.py +544 -0
- rootmark-0.1.0/tests/patches/test_strands.py +299 -0
- rootmark-0.1.0/tests/test_boto3_interceptor.py +1216 -0
- rootmark-0.1.0/tests/test_config_override.py +221 -0
- rootmark-0.1.0/tests/test_crewai_patch.py +180 -0
- rootmark-0.1.0/tests/test_langchain_handler.py +98 -0
- rootmark-0.1.0/tests/test_roundtrip_ingestion.py +127 -0
- rootmark-0.1.0/tests/test_span_tags.py +201 -0
- rootmark-0.1.0/tests/test_strands_agent_live.py +303 -0
- rootmark-0.1.0/tests/test_strands_multiagent.py +454 -0
- rootmark-0.1.0/tests/test_strands_multiagent_live.py +387 -0
- rootmark-0.1.0/tests/test_strands_patch.py +675 -0
- rootmark-0.1.0/tests/test_strands_session.py +290 -0
- rootmark-0.1.0/tests/test_suppress_payloads.py +185 -0
- rootmark-0.1.0/tests/test_trace_and_span.py +383 -0
- rootmark-0.0.1/LICENSE +0 -21
- rootmark-0.0.1/PKG-INFO +0 -22
- rootmark-0.0.1/README.md +0 -9
- rootmark-0.0.1/pyproject.toml +0 -20
- 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
|
rootmark-0.1.0/PKG-INFO
ADDED
|
@@ -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>
|
rootmark-0.1.0/README.md
ADDED
|
@@ -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,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()
|