metergraph 0.6.0__tar.gz → 0.6.2__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.
- {metergraph-0.6.0 → metergraph-0.6.2}/PKG-INFO +58 -10
- metergraph-0.6.0/src/metergraph.egg-info/PKG-INFO → metergraph-0.6.2/README.md +52 -29
- {metergraph-0.6.0 → metergraph-0.6.2}/pyproject.toml +3 -2
- {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/__init__.py +26 -11
- {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_capture.py +59 -2
- {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_context.py +117 -11
- metergraph-0.6.2/src/metergraph/_repo_config.py +63 -0
- {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_transport.py +1 -1
- {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_version.py +1 -1
- metergraph-0.6.2/src/metergraph/opentelemetry.py +221 -0
- metergraph-0.6.0/README.md → metergraph-0.6.2/src/metergraph.egg-info/PKG-INFO +77 -7
- {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph.egg-info/SOURCES.txt +1 -0
- metergraph-0.6.2/src/metergraph.egg-info/requires.txt +11 -0
- metergraph-0.6.0/src/metergraph/_repo_config.py +0 -165
- metergraph-0.6.0/src/metergraph.egg-info/requires.txt +0 -7
- {metergraph-0.6.0 → metergraph-0.6.2}/MANIFEST.in +0 -0
- {metergraph-0.6.0 → metergraph-0.6.2}/setup.cfg +0 -0
- {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_batch_first.py +0 -0
- {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_config.py +0 -0
- {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_failure_log.py +0 -0
- {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_provider_batch.py +0 -0
- {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_session.py +0 -0
- {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_template.py +0 -0
- {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_track.py +0 -0
- {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph.egg-info/dependency_links.txt +0 -0
- {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: metergraph
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
4
4
|
Summary: Fire-and-forget LLM spend capture for Metergraph
|
|
5
5
|
Author: Pioneer Square Labs
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -13,12 +13,15 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
13
13
|
Classifier: Typing :: Typed
|
|
14
14
|
Requires-Python: >=3.10
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
|
+
Provides-Extra: otel
|
|
17
|
+
Requires-Dist: opentelemetry-sdk>=1.30; extra == "otel"
|
|
16
18
|
Provides-Extra: dev
|
|
17
19
|
Requires-Dist: build>=1; extra == "dev"
|
|
18
20
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
19
|
-
Requires-Dist: openai
|
|
20
|
-
Requires-Dist: anthropic
|
|
21
|
+
Requires-Dist: openai<3,>=2.50.0; extra == "dev"
|
|
22
|
+
Requires-Dist: anthropic<1,>=0.40; extra == "dev"
|
|
21
23
|
Requires-Dist: google-genai>=1; extra == "dev"
|
|
24
|
+
Requires-Dist: opentelemetry-sdk>=1.30; extra == "dev"
|
|
22
25
|
|
|
23
26
|
# metergraph (Python)
|
|
24
27
|
|
|
@@ -38,12 +41,15 @@ from openai import OpenAI
|
|
|
38
41
|
metergraph.init(repository="owner/repository")
|
|
39
42
|
# Anthropic() and google-genai's genai.Client() wrap the same way.
|
|
40
43
|
client = metergraph.wrap(OpenAI())
|
|
41
|
-
metergraph.set_session("ticket-123")
|
|
42
44
|
|
|
43
|
-
with metergraph.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
with metergraph.context(
|
|
46
|
+
session_id="ticket-123",
|
|
47
|
+
tags={"customer": "acme"},
|
|
48
|
+
):
|
|
49
|
+
with metergraph.trace("ticket-workflow"):
|
|
50
|
+
with metergraph.route("ticket-classifier", unit="answer"):
|
|
51
|
+
model = metergraph.model_for("ticket-classifier", default="gpt-4.1-mini")
|
|
52
|
+
client.chat.completions.create(model=model, messages=[...])
|
|
47
53
|
|
|
48
54
|
# Emit this after the user-visible task resolves. It shares the bounded async
|
|
49
55
|
# transport and contains no prompt or output content.
|
|
@@ -57,6 +63,14 @@ metergraph.record_outcome(
|
|
|
57
63
|
)
|
|
58
64
|
```
|
|
59
65
|
|
|
66
|
+
Use `metergraph.context()` for request or job identity. It follows async work
|
|
67
|
+
created inside the scope and is restored afterward, so concurrent and reused
|
|
68
|
+
workers cannot leak session IDs or tags into one another. The narrower
|
|
69
|
+
`metergraph.session()` and `metergraph.tags()` scopes compose with it.
|
|
70
|
+
`metergraph.set_default_tags()` sets process-wide service metadata. Legacy
|
|
71
|
+
`set_session()` and `set_tags()` calls only update an active Metergraph scope;
|
|
72
|
+
outside one they warn once and do nothing.
|
|
73
|
+
|
|
60
74
|
Vercel's supported Python surface is AI Gateway through the OpenAI or
|
|
61
75
|
Anthropic SDK. Point either client at the public gateway and `wrap()` detects
|
|
62
76
|
it automatically:
|
|
@@ -83,12 +97,44 @@ async, streaming, tool calls, and OpenAI Responses API calls are captured. Use
|
|
|
83
97
|
`metergraph.wrap(client, provider="vercel")` only when a compatible client is
|
|
84
98
|
behind a custom gateway URL that cannot be detected automatically.
|
|
85
99
|
|
|
100
|
+
## OpenTelemetry GenAI export
|
|
101
|
+
|
|
102
|
+
`MetergraphGenAIExporter` is a standard OpenTelemetry span exporter for GenAI
|
|
103
|
+
semantic-convention spans. The currently qualified integration is LiteLLM:
|
|
104
|
+
install the optional integration and configure MeterGraph as LiteLLM's custom
|
|
105
|
+
exporter. Existing LiteLLM call sites remain unchanged and must not also be
|
|
106
|
+
wrapped.
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
python -m pip install 'metergraph[otel]' 'litellm[proxy]>=1.96.2,<2'
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
import litellm
|
|
114
|
+
from litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfig
|
|
115
|
+
from metergraph.opentelemetry import MetergraphGenAIExporter
|
|
116
|
+
|
|
117
|
+
litellm.callbacks.append(OpenTelemetry(OpenTelemetryConfig(
|
|
118
|
+
exporter=MetergraphGenAIExporter(),
|
|
119
|
+
capture_message_content="SPAN_ONLY",
|
|
120
|
+
)))
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The exporter preserves OpenTelemetry trace identity, model/provider metadata,
|
|
124
|
+
token usage, latency, system instructions, ordered messages, and text output.
|
|
125
|
+
Message content is explicitly enabled because it may be sensitive. Text parts
|
|
126
|
+
are retained and replayable in the current POC pipeline. Calls containing other
|
|
127
|
+
part types retain their model, usage, timing, and status metadata, but those
|
|
128
|
+
parts are not replayable yet. See the runnable
|
|
129
|
+
[`python-litellm-otel` example](../examples/python-litellm-otel/).
|
|
130
|
+
|
|
86
131
|
Configuration:
|
|
87
132
|
|
|
88
133
|
- `METERGRAPH_APP_TOKEN` — required bearer token
|
|
89
134
|
- `METERGRAPH_INGEST_URL` — optional override; defaults to the hosted HTTPS endpoint
|
|
90
135
|
- `METERGRAPH_REPOSITORY` — optional `owner/repository` identity; used by [MeterGraph Bot](https://github.com/apps/metergraph)
|
|
91
136
|
- `METERGRAPH_CAPTURE_TEXT=0` — opt out of content capture globally
|
|
137
|
+
- `METERGRAPH_TEXT_MAX_BYTES` — per-field content limit; defaults to 1 MiB
|
|
92
138
|
- `METERGRAPH_DISABLED=1` — process kill switch
|
|
93
139
|
- `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
|
|
94
140
|
|
|
@@ -110,11 +156,13 @@ identity, it warns once and continues capture without repository attribution.
|
|
|
110
156
|
|
|
111
157
|
Delivery is bounded and off the request path. Queue overflow or a collector
|
|
112
158
|
outage drops capture and increments internal counters; it never changes the
|
|
113
|
-
provider call. Each wire batch is bounded to
|
|
159
|
+
provider call. Each wire batch is bounded to 4 MiB after optional gzip.
|
|
114
160
|
By default, Metergraph captures the scrubbed provider request and a normalized
|
|
115
161
|
response envelope, including assistant content and tool calls. Provider
|
|
116
162
|
credentials and transport headers are removed. Request and response are each
|
|
117
|
-
limited to
|
|
163
|
+
limited to 1 MiB of UTF-8 by default with an explicit truncation marker. Set
|
|
164
|
+
`METERGRAPH_TEXT_MAX_BYTES` or initialize with `text_max_bytes=...` to raise
|
|
165
|
+
the per-field limit for larger prompts and responses.
|
|
118
166
|
`capture_text=False` on `route()` or `trace()` overrides the global content
|
|
119
167
|
policy for a sensitive operation. The equivalent initialization option is
|
|
120
168
|
`metergraph.init(capture_text=False)`. The public open-source server continues
|
|
@@ -1,25 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: metergraph
|
|
3
|
-
Version: 0.6.0
|
|
4
|
-
Summary: Fire-and-forget LLM spend capture for Metergraph
|
|
5
|
-
Author: Pioneer Square Labs
|
|
6
|
-
License-Expression: Apache-2.0
|
|
7
|
-
Project-URL: Homepage, https://www.metergraph.dev/
|
|
8
|
-
Project-URL: Repository, https://github.com/PioneerSquareLabs/metergraphsdk
|
|
9
|
-
Project-URL: Issues, https://github.com/PioneerSquareLabs/metergraphsdk/issues
|
|
10
|
-
Keywords: llm,observability,openai,anthropic,gemini,vercel-ai-gateway,cost-tracking
|
|
11
|
-
Classifier: Intended Audience :: Developers
|
|
12
|
-
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
13
|
-
Classifier: Typing :: Typed
|
|
14
|
-
Requires-Python: >=3.10
|
|
15
|
-
Description-Content-Type: text/markdown
|
|
16
|
-
Provides-Extra: dev
|
|
17
|
-
Requires-Dist: build>=1; extra == "dev"
|
|
18
|
-
Requires-Dist: pytest>=8; extra == "dev"
|
|
19
|
-
Requires-Dist: openai>=2.50.0; extra == "dev"
|
|
20
|
-
Requires-Dist: anthropic>=0.40; extra == "dev"
|
|
21
|
-
Requires-Dist: google-genai>=1; extra == "dev"
|
|
22
|
-
|
|
23
1
|
# metergraph (Python)
|
|
24
2
|
|
|
25
3
|
Zero-runtime-dependency capture for OpenAI, Anthropic, Gemini, and Python
|
|
@@ -38,12 +16,15 @@ from openai import OpenAI
|
|
|
38
16
|
metergraph.init(repository="owner/repository")
|
|
39
17
|
# Anthropic() and google-genai's genai.Client() wrap the same way.
|
|
40
18
|
client = metergraph.wrap(OpenAI())
|
|
41
|
-
metergraph.set_session("ticket-123")
|
|
42
19
|
|
|
43
|
-
with metergraph.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
20
|
+
with metergraph.context(
|
|
21
|
+
session_id="ticket-123",
|
|
22
|
+
tags={"customer": "acme"},
|
|
23
|
+
):
|
|
24
|
+
with metergraph.trace("ticket-workflow"):
|
|
25
|
+
with metergraph.route("ticket-classifier", unit="answer"):
|
|
26
|
+
model = metergraph.model_for("ticket-classifier", default="gpt-4.1-mini")
|
|
27
|
+
client.chat.completions.create(model=model, messages=[...])
|
|
47
28
|
|
|
48
29
|
# Emit this after the user-visible task resolves. It shares the bounded async
|
|
49
30
|
# transport and contains no prompt or output content.
|
|
@@ -57,6 +38,14 @@ metergraph.record_outcome(
|
|
|
57
38
|
)
|
|
58
39
|
```
|
|
59
40
|
|
|
41
|
+
Use `metergraph.context()` for request or job identity. It follows async work
|
|
42
|
+
created inside the scope and is restored afterward, so concurrent and reused
|
|
43
|
+
workers cannot leak session IDs or tags into one another. The narrower
|
|
44
|
+
`metergraph.session()` and `metergraph.tags()` scopes compose with it.
|
|
45
|
+
`metergraph.set_default_tags()` sets process-wide service metadata. Legacy
|
|
46
|
+
`set_session()` and `set_tags()` calls only update an active Metergraph scope;
|
|
47
|
+
outside one they warn once and do nothing.
|
|
48
|
+
|
|
60
49
|
Vercel's supported Python surface is AI Gateway through the OpenAI or
|
|
61
50
|
Anthropic SDK. Point either client at the public gateway and `wrap()` detects
|
|
62
51
|
it automatically:
|
|
@@ -83,12 +72,44 @@ async, streaming, tool calls, and OpenAI Responses API calls are captured. Use
|
|
|
83
72
|
`metergraph.wrap(client, provider="vercel")` only when a compatible client is
|
|
84
73
|
behind a custom gateway URL that cannot be detected automatically.
|
|
85
74
|
|
|
75
|
+
## OpenTelemetry GenAI export
|
|
76
|
+
|
|
77
|
+
`MetergraphGenAIExporter` is a standard OpenTelemetry span exporter for GenAI
|
|
78
|
+
semantic-convention spans. The currently qualified integration is LiteLLM:
|
|
79
|
+
install the optional integration and configure MeterGraph as LiteLLM's custom
|
|
80
|
+
exporter. Existing LiteLLM call sites remain unchanged and must not also be
|
|
81
|
+
wrapped.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
python -m pip install 'metergraph[otel]' 'litellm[proxy]>=1.96.2,<2'
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import litellm
|
|
89
|
+
from litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfig
|
|
90
|
+
from metergraph.opentelemetry import MetergraphGenAIExporter
|
|
91
|
+
|
|
92
|
+
litellm.callbacks.append(OpenTelemetry(OpenTelemetryConfig(
|
|
93
|
+
exporter=MetergraphGenAIExporter(),
|
|
94
|
+
capture_message_content="SPAN_ONLY",
|
|
95
|
+
)))
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The exporter preserves OpenTelemetry trace identity, model/provider metadata,
|
|
99
|
+
token usage, latency, system instructions, ordered messages, and text output.
|
|
100
|
+
Message content is explicitly enabled because it may be sensitive. Text parts
|
|
101
|
+
are retained and replayable in the current POC pipeline. Calls containing other
|
|
102
|
+
part types retain their model, usage, timing, and status metadata, but those
|
|
103
|
+
parts are not replayable yet. See the runnable
|
|
104
|
+
[`python-litellm-otel` example](../examples/python-litellm-otel/).
|
|
105
|
+
|
|
86
106
|
Configuration:
|
|
87
107
|
|
|
88
108
|
- `METERGRAPH_APP_TOKEN` — required bearer token
|
|
89
109
|
- `METERGRAPH_INGEST_URL` — optional override; defaults to the hosted HTTPS endpoint
|
|
90
110
|
- `METERGRAPH_REPOSITORY` — optional `owner/repository` identity; used by [MeterGraph Bot](https://github.com/apps/metergraph)
|
|
91
111
|
- `METERGRAPH_CAPTURE_TEXT=0` — opt out of content capture globally
|
|
112
|
+
- `METERGRAPH_TEXT_MAX_BYTES` — per-field content limit; defaults to 1 MiB
|
|
92
113
|
- `METERGRAPH_DISABLED=1` — process kill switch
|
|
93
114
|
- `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
|
|
94
115
|
|
|
@@ -110,11 +131,13 @@ identity, it warns once and continues capture without repository attribution.
|
|
|
110
131
|
|
|
111
132
|
Delivery is bounded and off the request path. Queue overflow or a collector
|
|
112
133
|
outage drops capture and increments internal counters; it never changes the
|
|
113
|
-
provider call. Each wire batch is bounded to
|
|
134
|
+
provider call. Each wire batch is bounded to 4 MiB after optional gzip.
|
|
114
135
|
By default, Metergraph captures the scrubbed provider request and a normalized
|
|
115
136
|
response envelope, including assistant content and tool calls. Provider
|
|
116
137
|
credentials and transport headers are removed. Request and response are each
|
|
117
|
-
limited to
|
|
138
|
+
limited to 1 MiB of UTF-8 by default with an explicit truncation marker. Set
|
|
139
|
+
`METERGRAPH_TEXT_MAX_BYTES` or initialize with `text_max_bytes=...` to raise
|
|
140
|
+
the per-field limit for larger prompts and responses.
|
|
118
141
|
`capture_text=False` on `route()` or `trace()` overrides the global content
|
|
119
142
|
policy for a sensitive operation. The equivalent initialization option is
|
|
120
143
|
`metergraph.init(capture_text=False)`. The public open-source server continues
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "metergraph"
|
|
3
|
-
version = "0.6.
|
|
3
|
+
version = "0.6.2"
|
|
4
4
|
description = "Fire-and-forget LLM spend capture for Metergraph"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -20,7 +20,8 @@ Repository = "https://github.com/PioneerSquareLabs/metergraphsdk"
|
|
|
20
20
|
Issues = "https://github.com/PioneerSquareLabs/metergraphsdk/issues"
|
|
21
21
|
|
|
22
22
|
[project.optional-dependencies]
|
|
23
|
-
|
|
23
|
+
otel = ["opentelemetry-sdk>=1.30"]
|
|
24
|
+
dev = ["build>=1", "pytest>=8", "openai>=2.50.0,<3", "anthropic>=0.40,<1", "google-genai>=1", "opentelemetry-sdk>=1.30"]
|
|
24
25
|
|
|
25
26
|
[build-system]
|
|
26
27
|
requires = ["setuptools>=68"]
|
|
@@ -10,10 +10,21 @@ import uuid
|
|
|
10
10
|
from datetime import datetime, timezone
|
|
11
11
|
from typing import Any, Callable
|
|
12
12
|
|
|
13
|
-
from ._capture import Options, Runtime, set_runtime
|
|
13
|
+
from ._capture import DEFAULT_TEXT_MAX_BYTES, Options, Runtime, set_runtime
|
|
14
14
|
from ._capture import wrap as _wrap
|
|
15
15
|
from ._config import ConfigPoller
|
|
16
|
-
from ._context import
|
|
16
|
+
from ._context import (
|
|
17
|
+
context,
|
|
18
|
+
route,
|
|
19
|
+
session,
|
|
20
|
+
set_default_tags,
|
|
21
|
+
set_session,
|
|
22
|
+
set_tags,
|
|
23
|
+
snapshot,
|
|
24
|
+
tags,
|
|
25
|
+
trace,
|
|
26
|
+
wrap_executor,
|
|
27
|
+
)
|
|
17
28
|
from ._batch_first import (
|
|
18
29
|
BatchFirstIneligibleError,
|
|
19
30
|
BatchFirstMetadata,
|
|
@@ -69,6 +80,7 @@ def init(
|
|
|
69
80
|
skip_frames: list[str] | None = None,
|
|
70
81
|
environment: str | None = None,
|
|
71
82
|
disabled: bool | None = None,
|
|
83
|
+
text_max_bytes: int | None = None,
|
|
72
84
|
) -> None:
|
|
73
85
|
"""Initialize capture. This function is idempotent and never raises."""
|
|
74
86
|
global _initialized, _warned_no_token, _warned_no_repository
|
|
@@ -136,15 +148,14 @@ def init(
|
|
|
136
148
|
repo_root=repo_config.repo_root if repo_config is not None else None,
|
|
137
149
|
skip_frames=tuple(skip_frames or ()),
|
|
138
150
|
environment=environment or os.getenv("METERGRAPH_ENV"),
|
|
139
|
-
text_max_bytes=
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
),
|
|
151
|
+
text_max_bytes=max(
|
|
152
|
+
1,
|
|
153
|
+
int(
|
|
154
|
+
text_max_bytes
|
|
155
|
+
if text_max_bytes is not None
|
|
156
|
+
else os.getenv(
|
|
157
|
+
"METERGRAPH_TEXT_MAX_BYTES", str(DEFAULT_TEXT_MAX_BYTES)
|
|
158
|
+
)
|
|
148
159
|
),
|
|
149
160
|
),
|
|
150
161
|
)
|
|
@@ -290,14 +301,18 @@ __all__ = [
|
|
|
290
301
|
"BatchFirstResult",
|
|
291
302
|
"LateBatchInfo",
|
|
292
303
|
"batch_first",
|
|
304
|
+
"context",
|
|
293
305
|
"flush",
|
|
294
306
|
"init",
|
|
295
307
|
"model_for",
|
|
296
308
|
"record_outcome",
|
|
297
309
|
"route",
|
|
310
|
+
"session",
|
|
311
|
+
"set_default_tags",
|
|
298
312
|
"set_session",
|
|
299
313
|
"set_tags",
|
|
300
314
|
"shutdown",
|
|
315
|
+
"tags",
|
|
301
316
|
"track",
|
|
302
317
|
"trace",
|
|
303
318
|
"wrap",
|
|
@@ -24,6 +24,7 @@ from ._version import SDK_VERSION
|
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
log = logging.getLogger("metergraph")
|
|
27
|
+
DEFAULT_TEXT_MAX_BYTES = 1024 * 1024
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
def _get(value: Any, name: str, default: Any = None) -> Any:
|
|
@@ -254,6 +255,48 @@ def _stop_reason(response: Any) -> str | None:
|
|
|
254
255
|
return str(reason) if reason is not None else None
|
|
255
256
|
|
|
256
257
|
|
|
258
|
+
def _normalize_finish_reason(value: str) -> str:
|
|
259
|
+
normalized = "-".join(value.strip().lower().replace("_", "-").split())
|
|
260
|
+
if normalized in {"stop", "end-turn", "stop-sequence", "completed", "succeeded"}:
|
|
261
|
+
return "stop"
|
|
262
|
+
if normalized in {"length", "max-tokens", "max-output-tokens"}:
|
|
263
|
+
return "length"
|
|
264
|
+
if normalized in {"content-filter", "safety", "blocked"}:
|
|
265
|
+
return "content-filter"
|
|
266
|
+
if normalized in {"tool-calls", "tool-use", "function-call"}:
|
|
267
|
+
return "tool-calls"
|
|
268
|
+
if normalized in {"error", "failed"}:
|
|
269
|
+
return "error"
|
|
270
|
+
if normalized in {"other", "unknown"}:
|
|
271
|
+
return "other"
|
|
272
|
+
return normalized
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _finish_reason_details(response: Any) -> tuple[str | None, str | None]:
|
|
276
|
+
response_status = _get(response, "status")
|
|
277
|
+
incomplete_reason = (
|
|
278
|
+
_get(_get(response, "incomplete_details"), "reason")
|
|
279
|
+
if response_status == "incomplete"
|
|
280
|
+
else None
|
|
281
|
+
)
|
|
282
|
+
value = (
|
|
283
|
+
_get(response, "stop_reason")
|
|
284
|
+
or incomplete_reason
|
|
285
|
+
or response_status
|
|
286
|
+
or _get(response, "finishReason")
|
|
287
|
+
or _get(response, "finish_reason")
|
|
288
|
+
or _get(_first(_get(response, "choices")), "finish_reason")
|
|
289
|
+
)
|
|
290
|
+
unified = _get(value, "unified")
|
|
291
|
+
raw_value = _get(value, "raw") or (value if unified is None else None)
|
|
292
|
+
source = unified if unified is not None else raw_value
|
|
293
|
+
if source is None:
|
|
294
|
+
return None, None
|
|
295
|
+
finish_reason = _normalize_finish_reason(str(source))
|
|
296
|
+
raw = str(raw_value) if raw_value is not None else None
|
|
297
|
+
return finish_reason, raw if raw != finish_reason else None
|
|
298
|
+
|
|
299
|
+
|
|
257
300
|
def _request_id(response: Any) -> str | None:
|
|
258
301
|
value = (
|
|
259
302
|
_get(response, "_request_id")
|
|
@@ -597,7 +640,7 @@ class Options:
|
|
|
597
640
|
repo_root: str | None = None
|
|
598
641
|
skip_frames: tuple[str, ...] = ()
|
|
599
642
|
environment: str | None = None
|
|
600
|
-
text_max_bytes: int =
|
|
643
|
+
text_max_bytes: int = DEFAULT_TEXT_MAX_BYTES
|
|
601
644
|
|
|
602
645
|
|
|
603
646
|
class Runtime:
|
|
@@ -740,6 +783,12 @@ class CallState:
|
|
|
740
783
|
effective_status = status or (
|
|
741
784
|
"error" if error else _stop_reason(response) or "success"
|
|
742
785
|
)
|
|
786
|
+
finish_reason, finish_reason_raw = _finish_reason_details(response)
|
|
787
|
+
status_code = (
|
|
788
|
+
"error"
|
|
789
|
+
if error or status == "error" or finish_reason == "error"
|
|
790
|
+
else "unset"
|
|
791
|
+
)
|
|
743
792
|
response_json, response_truncated = self.runtime._text(
|
|
744
793
|
json.dumps(
|
|
745
794
|
_response_envelope(
|
|
@@ -764,6 +813,9 @@ class CallState:
|
|
|
764
813
|
**_usage(response),
|
|
765
814
|
"latency_ms": round((time.perf_counter() - self.started) * 1000),
|
|
766
815
|
"status": effective_status,
|
|
816
|
+
"status_code": status_code,
|
|
817
|
+
"finish_reason": finish_reason,
|
|
818
|
+
"finish_reason_raw": finish_reason_raw,
|
|
767
819
|
"session_id": self.context.session_id,
|
|
768
820
|
"conversation_id": self.context.session_id,
|
|
769
821
|
"trace_id": self.trace_id,
|
|
@@ -792,7 +844,7 @@ class CallState:
|
|
|
792
844
|
"frames_json": self.frames,
|
|
793
845
|
"tags": dict(self.context.tags),
|
|
794
846
|
"environment": self.runtime.options.environment,
|
|
795
|
-
"error":
|
|
847
|
+
"error": status_code == "error",
|
|
796
848
|
"error_type": type(error).__name__ if error else None,
|
|
797
849
|
"sdk": "python",
|
|
798
850
|
"sdk_version": SDK_VERSION,
|
|
@@ -1004,6 +1056,11 @@ def set_runtime(runtime: Runtime | None) -> None:
|
|
|
1004
1056
|
_runtime = runtime
|
|
1005
1057
|
|
|
1006
1058
|
|
|
1059
|
+
def _get_runtime() -> Runtime | None:
|
|
1060
|
+
"""Return the active runtime to internal capture integrations."""
|
|
1061
|
+
return _runtime
|
|
1062
|
+
|
|
1063
|
+
|
|
1007
1064
|
def _request(args: tuple, kwargs: dict) -> dict[str, Any]:
|
|
1008
1065
|
request: dict[str, Any] = {}
|
|
1009
1066
|
if args and isinstance(args[0], Mapping):
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import contextvars
|
|
6
6
|
import functools
|
|
7
7
|
import inspect
|
|
8
|
+
import logging
|
|
8
9
|
import secrets
|
|
9
10
|
from concurrent.futures import Executor
|
|
10
11
|
from dataclasses import dataclass, field, replace
|
|
@@ -29,10 +30,97 @@ class CaptureContext:
|
|
|
29
30
|
_current: contextvars.ContextVar[CaptureContext] = contextvars.ContextVar(
|
|
30
31
|
"metergraph_context", default=CaptureContext()
|
|
31
32
|
)
|
|
33
|
+
_active_depth: contextvars.ContextVar[int] = contextvars.ContextVar(
|
|
34
|
+
"metergraph_context_depth", default=0
|
|
35
|
+
)
|
|
36
|
+
_default_tags: dict[str, str] = {}
|
|
37
|
+
_warned_session_outside_scope = False
|
|
38
|
+
_warned_tags_outside_scope = False
|
|
39
|
+
log = logging.getLogger("metergraph")
|
|
32
40
|
|
|
33
41
|
|
|
34
42
|
def snapshot() -> CaptureContext:
|
|
35
|
-
|
|
43
|
+
current = _current.get()
|
|
44
|
+
if _active_depth.get() == 0 and _default_tags:
|
|
45
|
+
return replace(current, tags={**_default_tags, **current.tags})
|
|
46
|
+
return current
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _enter_scope(value: CaptureContext):
|
|
50
|
+
return _current.set(value), _active_depth.set(_active_depth.get() + 1)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _exit_scope(tokens) -> None:
|
|
54
|
+
current_token, depth_token = tokens
|
|
55
|
+
_current.reset(current_token)
|
|
56
|
+
_active_depth.reset(depth_token)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class context:
|
|
60
|
+
"""Scoped session and tag context manager and sync/async decorator."""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
session_id: str | None = None,
|
|
66
|
+
tags: Mapping[str, Any] | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.session_id = str(session_id) if session_id is not None else None
|
|
69
|
+
self.tags = {str(k): str(v) for k, v in (tags or {}).items()}
|
|
70
|
+
self._tokens = None
|
|
71
|
+
|
|
72
|
+
def __enter__(self) -> "context":
|
|
73
|
+
current = snapshot()
|
|
74
|
+
self._tokens = _enter_scope(
|
|
75
|
+
replace(
|
|
76
|
+
current,
|
|
77
|
+
session_id=(
|
|
78
|
+
self.session_id
|
|
79
|
+
if self.session_id is not None
|
|
80
|
+
else current.session_id
|
|
81
|
+
),
|
|
82
|
+
tags={**current.tags, **self.tags},
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
88
|
+
if self._tokens is not None:
|
|
89
|
+
_exit_scope(self._tokens)
|
|
90
|
+
self._tokens = None
|
|
91
|
+
|
|
92
|
+
def __call__(self, fn: Callable):
|
|
93
|
+
if inspect.iscoroutinefunction(fn):
|
|
94
|
+
|
|
95
|
+
@functools.wraps(fn)
|
|
96
|
+
async def async_wrapped(*args, **kwargs):
|
|
97
|
+
with type(self)(session_id=self.session_id, tags=self.tags):
|
|
98
|
+
return await fn(*args, **kwargs)
|
|
99
|
+
|
|
100
|
+
return async_wrapped
|
|
101
|
+
|
|
102
|
+
@functools.wraps(fn)
|
|
103
|
+
def wrapped(*args, **kwargs):
|
|
104
|
+
with type(self)(session_id=self.session_id, tags=self.tags):
|
|
105
|
+
return fn(*args, **kwargs)
|
|
106
|
+
|
|
107
|
+
return wrapped
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def session(session_id: str | None) -> context:
|
|
111
|
+
"""Return a scope that overrides the current session ID."""
|
|
112
|
+
return context(session_id=session_id)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def tags(**values: Any) -> context:
|
|
116
|
+
"""Return a scope that merges tags into the current context."""
|
|
117
|
+
return context(tags=values)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def set_default_tags(**values: Any) -> None:
|
|
121
|
+
"""Replace process-wide tags inherited by new Metergraph scopes."""
|
|
122
|
+
global _default_tags
|
|
123
|
+
_default_tags = {str(k): str(v) for k, v in values.items()}
|
|
36
124
|
|
|
37
125
|
|
|
38
126
|
class route:
|
|
@@ -56,12 +144,12 @@ class route:
|
|
|
56
144
|
self.capture_text = (
|
|
57
145
|
bool(capture_text) if capture_text is not None else None
|
|
58
146
|
)
|
|
59
|
-
self.
|
|
147
|
+
self._tokens = None
|
|
60
148
|
|
|
61
149
|
def __enter__(self) -> "route":
|
|
62
150
|
current = snapshot()
|
|
63
151
|
merged = {**current.tags, **self.tags}
|
|
64
|
-
self.
|
|
152
|
+
self._tokens = _enter_scope(
|
|
65
153
|
replace(
|
|
66
154
|
current,
|
|
67
155
|
route=self.name,
|
|
@@ -78,9 +166,9 @@ class route:
|
|
|
78
166
|
return self
|
|
79
167
|
|
|
80
168
|
def __exit__(self, exc_type, exc, tb) -> None:
|
|
81
|
-
if self.
|
|
82
|
-
|
|
83
|
-
self.
|
|
169
|
+
if self._tokens is not None:
|
|
170
|
+
_exit_scope(self._tokens)
|
|
171
|
+
self._tokens = None
|
|
84
172
|
|
|
85
173
|
def __call__(self, fn: Callable):
|
|
86
174
|
if inspect.iscoroutinefunction(fn):
|
|
@@ -131,7 +219,7 @@ class trace:
|
|
|
131
219
|
self.capture_text = (
|
|
132
220
|
bool(capture_text) if capture_text is not None else None
|
|
133
221
|
)
|
|
134
|
-
self.
|
|
222
|
+
self._tokens = None
|
|
135
223
|
|
|
136
224
|
def __enter__(self) -> "trace":
|
|
137
225
|
current = snapshot()
|
|
@@ -139,7 +227,7 @@ class trace:
|
|
|
139
227
|
reuse = current.trace_id is not None and (
|
|
140
228
|
requested is None or requested == current.trace_id
|
|
141
229
|
)
|
|
142
|
-
self.
|
|
230
|
+
self._tokens = _enter_scope(
|
|
143
231
|
replace(
|
|
144
232
|
current,
|
|
145
233
|
trace_id=(
|
|
@@ -165,9 +253,9 @@ class trace:
|
|
|
165
253
|
return self
|
|
166
254
|
|
|
167
255
|
def __exit__(self, exc_type, exc, tb) -> None:
|
|
168
|
-
if self.
|
|
169
|
-
|
|
170
|
-
self.
|
|
256
|
+
if self._tokens is not None:
|
|
257
|
+
_exit_scope(self._tokens)
|
|
258
|
+
self._tokens = None
|
|
171
259
|
|
|
172
260
|
def __call__(self, fn: Callable):
|
|
173
261
|
if inspect.iscoroutinefunction(fn):
|
|
@@ -198,12 +286,30 @@ class trace:
|
|
|
198
286
|
|
|
199
287
|
|
|
200
288
|
def set_session(session_id: str | None) -> None:
|
|
289
|
+
global _warned_session_outside_scope
|
|
290
|
+
if _active_depth.get() == 0:
|
|
291
|
+
if not _warned_session_outside_scope:
|
|
292
|
+
_warned_session_outside_scope = True
|
|
293
|
+
log.warning(
|
|
294
|
+
"metergraph.set_session() requires an active Metergraph context; "
|
|
295
|
+
"use metergraph.context() or metergraph.session()."
|
|
296
|
+
)
|
|
297
|
+
return
|
|
201
298
|
_current.set(
|
|
202
299
|
replace(snapshot(), session_id=str(session_id) if session_id else None)
|
|
203
300
|
)
|
|
204
301
|
|
|
205
302
|
|
|
206
303
|
def set_tags(**tags: Any) -> None:
|
|
304
|
+
global _warned_tags_outside_scope
|
|
305
|
+
if _active_depth.get() == 0:
|
|
306
|
+
if not _warned_tags_outside_scope:
|
|
307
|
+
_warned_tags_outside_scope = True
|
|
308
|
+
log.warning(
|
|
309
|
+
"metergraph.set_tags() requires an active Metergraph context; "
|
|
310
|
+
"use metergraph.context() or metergraph.tags()."
|
|
311
|
+
)
|
|
312
|
+
return
|
|
207
313
|
current = snapshot()
|
|
208
314
|
merged = {**current.tags, **{str(k): str(v) for k, v in tags.items()}}
|
|
209
315
|
_current.set(replace(current, tags=merged))
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Read-only discovery of repository identity configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger("metergraph")
|
|
12
|
+
CONFIG_DIRNAME = ".metergraph"
|
|
13
|
+
CONFIG_FILENAME = "config.json"
|
|
14
|
+
SUPPORTED_CONFIG_VERSION = 2
|
|
15
|
+
_MAX_WALK_UP = 64
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class RepoConfig:
|
|
20
|
+
repository: str
|
|
21
|
+
repo_root: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def discover_repo_config(app_root: str) -> RepoConfig | None:
|
|
25
|
+
"""Walk upward from app_root looking for .metergraph/config.json."""
|
|
26
|
+
current = os.path.realpath(app_root)
|
|
27
|
+
for _ in range(_MAX_WALK_UP):
|
|
28
|
+
candidate = os.path.join(current, CONFIG_DIRNAME, CONFIG_FILENAME)
|
|
29
|
+
if os.path.isfile(candidate):
|
|
30
|
+
return _load(candidate, current)
|
|
31
|
+
parent = os.path.dirname(current)
|
|
32
|
+
if parent == current:
|
|
33
|
+
break
|
|
34
|
+
current = parent
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load(path: str, repo_root: str) -> RepoConfig | None:
|
|
39
|
+
try:
|
|
40
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
41
|
+
doc = json.load(handle)
|
|
42
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
43
|
+
log.warning("metergraph: found %s but could not read it: %s", path, exc)
|
|
44
|
+
return None
|
|
45
|
+
if (
|
|
46
|
+
not isinstance(doc, dict)
|
|
47
|
+
or doc.get("version", SUPPORTED_CONFIG_VERSION)
|
|
48
|
+
!= SUPPORTED_CONFIG_VERSION
|
|
49
|
+
):
|
|
50
|
+
log.warning(
|
|
51
|
+
"metergraph: %s has an unsupported schema version; ignoring "
|
|
52
|
+
"(expected version %d)",
|
|
53
|
+
path,
|
|
54
|
+
SUPPORTED_CONFIG_VERSION,
|
|
55
|
+
)
|
|
56
|
+
return None
|
|
57
|
+
repository = doc.get("repository")
|
|
58
|
+
if not isinstance(repository, str) or "/" not in repository:
|
|
59
|
+
log.warning(
|
|
60
|
+
"metergraph: %s is missing a valid 'repository' field; ignoring", path
|
|
61
|
+
)
|
|
62
|
+
return None
|
|
63
|
+
return RepoConfig(repository=repository, repo_root=repo_root)
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""OpenTelemetry GenAI span export through MeterGraph's existing transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, Mapping, Sequence
|
|
9
|
+
|
|
10
|
+
import metergraph
|
|
11
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
|
12
|
+
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
|
13
|
+
from opentelemetry.trace import StatusCode
|
|
14
|
+
|
|
15
|
+
from ._capture import _get_runtime
|
|
16
|
+
from ._context import CaptureContext
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OpenTelemetrySpanError(Exception):
|
|
20
|
+
"""Internal marker used to preserve an exported span's error status."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _attribute(attributes: Mapping[str, Any], name: str) -> Any:
|
|
24
|
+
return attributes.get(name)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _first_string(value: Any) -> str | None:
|
|
28
|
+
if isinstance(value, str):
|
|
29
|
+
try:
|
|
30
|
+
decoded = json.loads(value)
|
|
31
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
32
|
+
decoded = None
|
|
33
|
+
if isinstance(decoded, list) and decoded:
|
|
34
|
+
return str(decoded[0])
|
|
35
|
+
return value
|
|
36
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
|
37
|
+
return str(value[0]) if value else None
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _text_from_output_messages(value: Any) -> str | None:
|
|
42
|
+
if not isinstance(value, str):
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
messages = json.loads(value)
|
|
46
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
47
|
+
return None
|
|
48
|
+
if not isinstance(messages, list):
|
|
49
|
+
return None
|
|
50
|
+
for message in messages:
|
|
51
|
+
if not isinstance(message, Mapping):
|
|
52
|
+
continue
|
|
53
|
+
parts = message.get("parts")
|
|
54
|
+
if not isinstance(parts, list):
|
|
55
|
+
continue
|
|
56
|
+
texts: list[str] = []
|
|
57
|
+
for part in parts:
|
|
58
|
+
if (
|
|
59
|
+
isinstance(part, Mapping)
|
|
60
|
+
and part.get("type") == "text"
|
|
61
|
+
and isinstance(part.get("content"), str)
|
|
62
|
+
):
|
|
63
|
+
texts.append(part["content"])
|
|
64
|
+
if texts:
|
|
65
|
+
return "".join(texts)
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _request_content(attributes: Mapping[str, Any]) -> tuple[str | None, str | None]:
|
|
70
|
+
system = _attribute(attributes, "gen_ai.system_instructions")
|
|
71
|
+
messages = _attribute(attributes, "gen_ai.input.messages")
|
|
72
|
+
if not isinstance(messages, str):
|
|
73
|
+
return system if isinstance(system, str) else None, None
|
|
74
|
+
if isinstance(system, str):
|
|
75
|
+
return system, messages
|
|
76
|
+
try:
|
|
77
|
+
decoded = json.loads(messages)
|
|
78
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
79
|
+
return None, messages
|
|
80
|
+
if not isinstance(decoded, list):
|
|
81
|
+
return None, messages
|
|
82
|
+
|
|
83
|
+
system_parts: list[Any] = []
|
|
84
|
+
conversation: list[Any] = []
|
|
85
|
+
for message in decoded:
|
|
86
|
+
if isinstance(message, Mapping) and message.get("role") == "system":
|
|
87
|
+
parts = message.get("parts")
|
|
88
|
+
if isinstance(parts, list):
|
|
89
|
+
system_parts.extend(parts)
|
|
90
|
+
continue
|
|
91
|
+
conversation.append(message)
|
|
92
|
+
if not system_parts:
|
|
93
|
+
return None, messages
|
|
94
|
+
return (
|
|
95
|
+
json.dumps(system_parts, separators=(",", ":"), ensure_ascii=False),
|
|
96
|
+
json.dumps(conversation, separators=(",", ":"), ensure_ascii=False),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _hex_id(value: int, width: int) -> str | None:
|
|
101
|
+
return f"{value:0{width}x}" if value else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class MetergraphGenAIExporter(SpanExporter):
|
|
105
|
+
"""Export completed OpenTelemetry GenAI spans through MeterGraph."""
|
|
106
|
+
|
|
107
|
+
def __init__(self) -> None:
|
|
108
|
+
metergraph.init()
|
|
109
|
+
|
|
110
|
+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
|
111
|
+
runtime = _get_runtime()
|
|
112
|
+
if runtime is None:
|
|
113
|
+
return SpanExportResult.SUCCESS
|
|
114
|
+
for span in spans:
|
|
115
|
+
try:
|
|
116
|
+
self._export_span(runtime, span)
|
|
117
|
+
except Exception:
|
|
118
|
+
# Observability must never break the application or its OTel pipeline.
|
|
119
|
+
continue
|
|
120
|
+
return SpanExportResult.SUCCESS
|
|
121
|
+
|
|
122
|
+
def _export_span(self, runtime: Any, span: ReadableSpan) -> None:
|
|
123
|
+
attributes = span.attributes or {}
|
|
124
|
+
model = _attribute(attributes, "gen_ai.request.model")
|
|
125
|
+
provider = _attribute(attributes, "gen_ai.provider.name") or _attribute(
|
|
126
|
+
attributes, "gen_ai.system"
|
|
127
|
+
)
|
|
128
|
+
if not isinstance(model, str) or not isinstance(provider, str):
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
operation = _attribute(attributes, "gen_ai.operation.name")
|
|
132
|
+
operation = operation if isinstance(operation, str) else "inference"
|
|
133
|
+
request: dict[str, Any] = {"model": model}
|
|
134
|
+
system, messages = _request_content(attributes)
|
|
135
|
+
if system is not None:
|
|
136
|
+
request["system_instructions"] = system
|
|
137
|
+
if messages is not None:
|
|
138
|
+
request["messages"] = messages
|
|
139
|
+
|
|
140
|
+
context = span.context
|
|
141
|
+
parent = span.parent
|
|
142
|
+
resource_attributes = (
|
|
143
|
+
span.resource.attributes if span.resource is not None else {}
|
|
144
|
+
)
|
|
145
|
+
function_name = _attribute(attributes, "code.function.name")
|
|
146
|
+
if not isinstance(function_name, str) or not function_name:
|
|
147
|
+
function_name = span.name
|
|
148
|
+
function_module = _attribute(attributes, "code.namespace") or _attribute(
|
|
149
|
+
resource_attributes, "service.name"
|
|
150
|
+
)
|
|
151
|
+
if not isinstance(function_module, str):
|
|
152
|
+
function_module = None
|
|
153
|
+
trace_id = _hex_id(context.trace_id, 32) if context is not None else None
|
|
154
|
+
span_id = _hex_id(context.span_id, 16) if context is not None else None
|
|
155
|
+
parent_span_id = _hex_id(parent.span_id, 16) if parent is not None else None
|
|
156
|
+
call = runtime.call_state(
|
|
157
|
+
provider,
|
|
158
|
+
operation,
|
|
159
|
+
request,
|
|
160
|
+
context=CaptureContext(
|
|
161
|
+
route=operation,
|
|
162
|
+
trace_id=trace_id,
|
|
163
|
+
trace_name=span.name,
|
|
164
|
+
parent_span_id=parent_span_id,
|
|
165
|
+
func_name=function_name,
|
|
166
|
+
func_module=function_module,
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
if span_id is not None:
|
|
170
|
+
call.span_id = span_id
|
|
171
|
+
if span.start_time is not None:
|
|
172
|
+
call.ts = datetime.fromtimestamp(
|
|
173
|
+
span.start_time / 1_000_000_000, tz=timezone.utc
|
|
174
|
+
).isoformat()
|
|
175
|
+
if span.start_time is not None and span.end_time is not None:
|
|
176
|
+
duration_seconds = max(0, span.end_time - span.start_time) / 1_000_000_000
|
|
177
|
+
call.started = time.perf_counter() - duration_seconds
|
|
178
|
+
|
|
179
|
+
response_model = _attribute(attributes, "gen_ai.response.model")
|
|
180
|
+
finish_reason = _first_string(
|
|
181
|
+
_attribute(attributes, "gen_ai.response.finish_reasons")
|
|
182
|
+
)
|
|
183
|
+
response = {
|
|
184
|
+
"model": response_model if isinstance(response_model, str) else model,
|
|
185
|
+
"usage": {
|
|
186
|
+
"input_tokens": _attribute(
|
|
187
|
+
attributes, "gen_ai.usage.input_tokens"
|
|
188
|
+
),
|
|
189
|
+
"output_tokens": _attribute(
|
|
190
|
+
attributes, "gen_ai.usage.output_tokens"
|
|
191
|
+
),
|
|
192
|
+
},
|
|
193
|
+
"finish_reason": finish_reason,
|
|
194
|
+
"choices": (
|
|
195
|
+
[{"finish_reason": finish_reason}]
|
|
196
|
+
if finish_reason is not None
|
|
197
|
+
else []
|
|
198
|
+
),
|
|
199
|
+
}
|
|
200
|
+
output_text = _text_from_output_messages(
|
|
201
|
+
_attribute(attributes, "gen_ai.output.messages")
|
|
202
|
+
)
|
|
203
|
+
if span.status.status_code is StatusCode.ERROR:
|
|
204
|
+
call.finish(
|
|
205
|
+
response,
|
|
206
|
+
error=OpenTelemetrySpanError(
|
|
207
|
+
span.status.description or "OpenTelemetry GenAI span failed"
|
|
208
|
+
),
|
|
209
|
+
response_text=output_text,
|
|
210
|
+
)
|
|
211
|
+
else:
|
|
212
|
+
call.finish(response, response_text=output_text)
|
|
213
|
+
|
|
214
|
+
def force_flush(self, timeout_millis: int = 30_000) -> bool:
|
|
215
|
+
return metergraph.flush(max(0, timeout_millis) / 1000)
|
|
216
|
+
|
|
217
|
+
def shutdown(self) -> None:
|
|
218
|
+
metergraph.shutdown()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
__all__ = ["MetergraphGenAIExporter"]
|
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: metergraph
|
|
3
|
+
Version: 0.6.2
|
|
4
|
+
Summary: Fire-and-forget LLM spend capture for Metergraph
|
|
5
|
+
Author: Pioneer Square Labs
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://www.metergraph.dev/
|
|
8
|
+
Project-URL: Repository, https://github.com/PioneerSquareLabs/metergraphsdk
|
|
9
|
+
Project-URL: Issues, https://github.com/PioneerSquareLabs/metergraphsdk/issues
|
|
10
|
+
Keywords: llm,observability,openai,anthropic,gemini,vercel-ai-gateway,cost-tracking
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Provides-Extra: otel
|
|
17
|
+
Requires-Dist: opentelemetry-sdk>=1.30; extra == "otel"
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: build>=1; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
21
|
+
Requires-Dist: openai<3,>=2.50.0; extra == "dev"
|
|
22
|
+
Requires-Dist: anthropic<1,>=0.40; extra == "dev"
|
|
23
|
+
Requires-Dist: google-genai>=1; extra == "dev"
|
|
24
|
+
Requires-Dist: opentelemetry-sdk>=1.30; extra == "dev"
|
|
25
|
+
|
|
1
26
|
# metergraph (Python)
|
|
2
27
|
|
|
3
28
|
Zero-runtime-dependency capture for OpenAI, Anthropic, Gemini, and Python
|
|
@@ -16,12 +41,15 @@ from openai import OpenAI
|
|
|
16
41
|
metergraph.init(repository="owner/repository")
|
|
17
42
|
# Anthropic() and google-genai's genai.Client() wrap the same way.
|
|
18
43
|
client = metergraph.wrap(OpenAI())
|
|
19
|
-
metergraph.set_session("ticket-123")
|
|
20
44
|
|
|
21
|
-
with metergraph.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
45
|
+
with metergraph.context(
|
|
46
|
+
session_id="ticket-123",
|
|
47
|
+
tags={"customer": "acme"},
|
|
48
|
+
):
|
|
49
|
+
with metergraph.trace("ticket-workflow"):
|
|
50
|
+
with metergraph.route("ticket-classifier", unit="answer"):
|
|
51
|
+
model = metergraph.model_for("ticket-classifier", default="gpt-4.1-mini")
|
|
52
|
+
client.chat.completions.create(model=model, messages=[...])
|
|
25
53
|
|
|
26
54
|
# Emit this after the user-visible task resolves. It shares the bounded async
|
|
27
55
|
# transport and contains no prompt or output content.
|
|
@@ -35,6 +63,14 @@ metergraph.record_outcome(
|
|
|
35
63
|
)
|
|
36
64
|
```
|
|
37
65
|
|
|
66
|
+
Use `metergraph.context()` for request or job identity. It follows async work
|
|
67
|
+
created inside the scope and is restored afterward, so concurrent and reused
|
|
68
|
+
workers cannot leak session IDs or tags into one another. The narrower
|
|
69
|
+
`metergraph.session()` and `metergraph.tags()` scopes compose with it.
|
|
70
|
+
`metergraph.set_default_tags()` sets process-wide service metadata. Legacy
|
|
71
|
+
`set_session()` and `set_tags()` calls only update an active Metergraph scope;
|
|
72
|
+
outside one they warn once and do nothing.
|
|
73
|
+
|
|
38
74
|
Vercel's supported Python surface is AI Gateway through the OpenAI or
|
|
39
75
|
Anthropic SDK. Point either client at the public gateway and `wrap()` detects
|
|
40
76
|
it automatically:
|
|
@@ -61,12 +97,44 @@ async, streaming, tool calls, and OpenAI Responses API calls are captured. Use
|
|
|
61
97
|
`metergraph.wrap(client, provider="vercel")` only when a compatible client is
|
|
62
98
|
behind a custom gateway URL that cannot be detected automatically.
|
|
63
99
|
|
|
100
|
+
## OpenTelemetry GenAI export
|
|
101
|
+
|
|
102
|
+
`MetergraphGenAIExporter` is a standard OpenTelemetry span exporter for GenAI
|
|
103
|
+
semantic-convention spans. The currently qualified integration is LiteLLM:
|
|
104
|
+
install the optional integration and configure MeterGraph as LiteLLM's custom
|
|
105
|
+
exporter. Existing LiteLLM call sites remain unchanged and must not also be
|
|
106
|
+
wrapped.
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
python -m pip install 'metergraph[otel]' 'litellm[proxy]>=1.96.2,<2'
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
import litellm
|
|
114
|
+
from litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfig
|
|
115
|
+
from metergraph.opentelemetry import MetergraphGenAIExporter
|
|
116
|
+
|
|
117
|
+
litellm.callbacks.append(OpenTelemetry(OpenTelemetryConfig(
|
|
118
|
+
exporter=MetergraphGenAIExporter(),
|
|
119
|
+
capture_message_content="SPAN_ONLY",
|
|
120
|
+
)))
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The exporter preserves OpenTelemetry trace identity, model/provider metadata,
|
|
124
|
+
token usage, latency, system instructions, ordered messages, and text output.
|
|
125
|
+
Message content is explicitly enabled because it may be sensitive. Text parts
|
|
126
|
+
are retained and replayable in the current POC pipeline. Calls containing other
|
|
127
|
+
part types retain their model, usage, timing, and status metadata, but those
|
|
128
|
+
parts are not replayable yet. See the runnable
|
|
129
|
+
[`python-litellm-otel` example](../examples/python-litellm-otel/).
|
|
130
|
+
|
|
64
131
|
Configuration:
|
|
65
132
|
|
|
66
133
|
- `METERGRAPH_APP_TOKEN` — required bearer token
|
|
67
134
|
- `METERGRAPH_INGEST_URL` — optional override; defaults to the hosted HTTPS endpoint
|
|
68
135
|
- `METERGRAPH_REPOSITORY` — optional `owner/repository` identity; used by [MeterGraph Bot](https://github.com/apps/metergraph)
|
|
69
136
|
- `METERGRAPH_CAPTURE_TEXT=0` — opt out of content capture globally
|
|
137
|
+
- `METERGRAPH_TEXT_MAX_BYTES` — per-field content limit; defaults to 1 MiB
|
|
70
138
|
- `METERGRAPH_DISABLED=1` — process kill switch
|
|
71
139
|
- `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
|
|
72
140
|
|
|
@@ -88,11 +156,13 @@ identity, it warns once and continues capture without repository attribution.
|
|
|
88
156
|
|
|
89
157
|
Delivery is bounded and off the request path. Queue overflow or a collector
|
|
90
158
|
outage drops capture and increments internal counters; it never changes the
|
|
91
|
-
provider call. Each wire batch is bounded to
|
|
159
|
+
provider call. Each wire batch is bounded to 4 MiB after optional gzip.
|
|
92
160
|
By default, Metergraph captures the scrubbed provider request and a normalized
|
|
93
161
|
response envelope, including assistant content and tool calls. Provider
|
|
94
162
|
credentials and transport headers are removed. Request and response are each
|
|
95
|
-
limited to
|
|
163
|
+
limited to 1 MiB of UTF-8 by default with an explicit truncation marker. Set
|
|
164
|
+
`METERGRAPH_TEXT_MAX_BYTES` or initialize with `text_max_bytes=...` to raise
|
|
165
|
+
the per-field limit for larger prompts and responses.
|
|
96
166
|
`capture_text=False` on `route()` or `trace()` overrides the global content
|
|
97
167
|
policy for a sensitive operation. The equivalent initialization option is
|
|
98
168
|
`metergraph.init(capture_text=False)`. The public open-source server continues
|
|
@@ -14,6 +14,7 @@ src/metergraph/_template.py
|
|
|
14
14
|
src/metergraph/_track.py
|
|
15
15
|
src/metergraph/_transport.py
|
|
16
16
|
src/metergraph/_version.py
|
|
17
|
+
src/metergraph/opentelemetry.py
|
|
17
18
|
src/metergraph.egg-info/PKG-INFO
|
|
18
19
|
src/metergraph.egg-info/SOURCES.txt
|
|
19
20
|
src/metergraph.egg-info/dependency_links.txt
|
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
"""SDK 0.4+ repository-aware ingestion: discover, detect, and write
|
|
2
|
-
.metergraph/config.json.
|
|
3
|
-
|
|
4
|
-
Discovery is purely file-based (never shells out to git), so a committed
|
|
5
|
-
config is honored in production without needing a .git directory at all.
|
|
6
|
-
Detection + write only ever runs when discovery finds nothing: it shells out
|
|
7
|
-
to git to find the repo's top level and GitHub origin, then writes the config
|
|
8
|
-
there exactly once. An existing file is always authoritative and is never
|
|
9
|
-
overwritten. Every failure mode here is fail-open -- callers get None and
|
|
10
|
-
fall back to app-token ingestion, never an exception.
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import json
|
|
16
|
-
import logging
|
|
17
|
-
import os
|
|
18
|
-
import re
|
|
19
|
-
import subprocess
|
|
20
|
-
from dataclasses import dataclass
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
log = logging.getLogger("metergraph")
|
|
24
|
-
|
|
25
|
-
CONFIG_DIRNAME = ".metergraph"
|
|
26
|
-
CONFIG_FILENAME = "config.json"
|
|
27
|
-
SUPPORTED_CONFIG_VERSION = 2
|
|
28
|
-
_MAX_WALK_UP = 64
|
|
29
|
-
_GIT_TIMEOUT_SECONDS = 5
|
|
30
|
-
|
|
31
|
-
_REMOTE_PATTERNS = (
|
|
32
|
-
re.compile(r"^git@github\.com:(?P<path>[^/]+/[^/]+?)(\.git)?/?$"),
|
|
33
|
-
re.compile(r"^https://github\.com/(?P<path>[^/]+/[^/]+?)(\.git)?/?$"),
|
|
34
|
-
re.compile(r"^ssh://git@github\.com/(?P<path>[^/]+/[^/]+?)(\.git)?/?$"),
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
@dataclass(frozen=True)
|
|
39
|
-
class RepoConfig:
|
|
40
|
-
repository: str
|
|
41
|
-
repo_root: str
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def normalize_github_remote(url: str) -> str | None:
|
|
45
|
-
"""Return 'owner/repo' from a GitHub SSH or HTTPS remote URL, or None
|
|
46
|
-
if the URL isn't a recognized GitHub origin."""
|
|
47
|
-
trimmed = url.strip()
|
|
48
|
-
for pattern in _REMOTE_PATTERNS:
|
|
49
|
-
match = pattern.match(trimmed)
|
|
50
|
-
if match:
|
|
51
|
-
return match.group("path")
|
|
52
|
-
return None
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def discover_repo_config(app_root: str) -> RepoConfig | None:
|
|
56
|
-
"""Walk upward from app_root looking for .metergraph/config.json.
|
|
57
|
-
|
|
58
|
-
Returns None -- silently, this is the normal v1 state -- when nothing is
|
|
59
|
-
found. Logs a warning (but still returns None) if a config file exists
|
|
60
|
-
but fails to parse or carries an unsupported schema version.
|
|
61
|
-
"""
|
|
62
|
-
current = os.path.realpath(app_root)
|
|
63
|
-
for _ in range(_MAX_WALK_UP):
|
|
64
|
-
candidate = os.path.join(current, CONFIG_DIRNAME, CONFIG_FILENAME)
|
|
65
|
-
if os.path.isfile(candidate):
|
|
66
|
-
return _load(candidate, current)
|
|
67
|
-
parent = os.path.dirname(current)
|
|
68
|
-
if parent == current:
|
|
69
|
-
break
|
|
70
|
-
current = parent
|
|
71
|
-
return None
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def _load(path: str, repo_root: str) -> RepoConfig | None:
|
|
75
|
-
try:
|
|
76
|
-
with open(path, "r", encoding="utf-8") as handle:
|
|
77
|
-
doc = json.load(handle)
|
|
78
|
-
except (OSError, json.JSONDecodeError) as exc:
|
|
79
|
-
log.warning("metergraph: found %s but could not read it: %s", path, exc)
|
|
80
|
-
return None
|
|
81
|
-
if not isinstance(doc, dict) or doc.get("version", SUPPORTED_CONFIG_VERSION) != SUPPORTED_CONFIG_VERSION:
|
|
82
|
-
log.warning(
|
|
83
|
-
"metergraph: %s has an unsupported schema version; ignoring "
|
|
84
|
-
"(expected version %d)",
|
|
85
|
-
path,
|
|
86
|
-
SUPPORTED_CONFIG_VERSION,
|
|
87
|
-
)
|
|
88
|
-
return None
|
|
89
|
-
repository = doc.get("repository")
|
|
90
|
-
if not isinstance(repository, str) or "/" not in repository:
|
|
91
|
-
log.warning("metergraph: %s is missing a valid 'repository' field; ignoring", path)
|
|
92
|
-
return None
|
|
93
|
-
return RepoConfig(repository=repository, repo_root=repo_root)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def _run_git(args: list[str], cwd: str) -> str | None:
|
|
97
|
-
try:
|
|
98
|
-
result = subprocess.run(
|
|
99
|
-
["git", *args],
|
|
100
|
-
cwd=cwd,
|
|
101
|
-
capture_output=True,
|
|
102
|
-
text=True,
|
|
103
|
-
timeout=_GIT_TIMEOUT_SECONDS,
|
|
104
|
-
)
|
|
105
|
-
except (OSError, subprocess.TimeoutExpired):
|
|
106
|
-
return None
|
|
107
|
-
if result.returncode != 0:
|
|
108
|
-
return None
|
|
109
|
-
output = result.stdout.strip()
|
|
110
|
-
return output or None
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def _git_top_level(app_root: str) -> str | None:
|
|
114
|
-
top = _run_git(["rev-parse", "--show-toplevel"], app_root)
|
|
115
|
-
return os.path.realpath(top) if top else None
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def _git_origin_url(repo_root: str) -> str | None:
|
|
119
|
-
return _run_git(["remote", "get-url", "origin"], repo_root)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def _write_config_atomically(repo_root: str, repository: str) -> RepoConfig | None:
|
|
123
|
-
"""Create .metergraph/config.json if -- and only if -- it doesn't
|
|
124
|
-
already exist. Uses O_CREAT|O_EXCL for an atomic create-only-if-absent;
|
|
125
|
-
a concurrent writer (or a file that appeared between discovery and this
|
|
126
|
-
call) always wins over us, and we simply read back whatever is there."""
|
|
127
|
-
config_dir = os.path.join(repo_root, CONFIG_DIRNAME)
|
|
128
|
-
config_path = os.path.join(config_dir, CONFIG_FILENAME)
|
|
129
|
-
payload = json.dumps({"version": SUPPORTED_CONFIG_VERSION, "repository": repository}) + "\n"
|
|
130
|
-
try:
|
|
131
|
-
os.makedirs(config_dir, exist_ok=True)
|
|
132
|
-
fd = os.open(config_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
133
|
-
try:
|
|
134
|
-
os.write(fd, payload.encode("utf-8"))
|
|
135
|
-
finally:
|
|
136
|
-
os.close(fd)
|
|
137
|
-
except FileExistsError:
|
|
138
|
-
pass
|
|
139
|
-
except OSError as exc:
|
|
140
|
-
log.warning("metergraph: could not write %s: %s", config_path, exc)
|
|
141
|
-
return None
|
|
142
|
-
return _load(config_path, repo_root)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def ensure_repo_config(app_root: str) -> RepoConfig | None:
|
|
146
|
-
"""Discover an existing repo config, or detect+write one once at the
|
|
147
|
-
git top level. Fail-open: any detection or write failure returns None
|
|
148
|
-
(app-token ingestion), never raises."""
|
|
149
|
-
existing = discover_repo_config(app_root)
|
|
150
|
-
if existing is not None:
|
|
151
|
-
return existing
|
|
152
|
-
try:
|
|
153
|
-
repo_root = _git_top_level(app_root)
|
|
154
|
-
if repo_root is None:
|
|
155
|
-
return None
|
|
156
|
-
origin = _git_origin_url(repo_root)
|
|
157
|
-
if origin is None:
|
|
158
|
-
return None
|
|
159
|
-
repository = normalize_github_remote(origin)
|
|
160
|
-
if repository is None:
|
|
161
|
-
return None
|
|
162
|
-
return _write_config_atomically(repo_root, repository)
|
|
163
|
-
except Exception as exc:
|
|
164
|
-
log.debug("metergraph: repo config detection failed: %s", exc)
|
|
165
|
-
return None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|