metergraph 0.5.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.2/MANIFEST.in +1 -0
- {metergraph-0.5.0 → metergraph-0.6.2}/PKG-INFO +92 -27
- {metergraph-0.5.0 → metergraph-0.6.2}/README.md +85 -24
- {metergraph-0.5.0 → metergraph-0.6.2}/pyproject.toml +3 -2
- {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/__init__.py +61 -16
- {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_capture.py +59 -2
- {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_context.py +117 -11
- metergraph-0.6.2/src/metergraph/_repo_config.py +63 -0
- {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_session.py +1 -1
- {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_transport.py +1 -1
- {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_version.py +1 -1
- metergraph-0.6.2/src/metergraph/opentelemetry.py +221 -0
- {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph.egg-info/PKG-INFO +92 -27
- {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph.egg-info/SOURCES.txt +3 -13
- metergraph-0.6.2/src/metergraph.egg-info/requires.txt +11 -0
- metergraph-0.5.0/src/metergraph/_repo_config.py +0 -165
- metergraph-0.5.0/src/metergraph.egg-info/requires.txt +0 -6
- metergraph-0.5.0/tests/test_batch_first.py +0 -595
- metergraph-0.5.0/tests/test_capture_repo_root.py +0 -101
- metergraph-0.5.0/tests/test_edge_cases.py +0 -556
- metergraph-0.5.0/tests/test_init_repo_aware.py +0 -110
- metergraph-0.5.0/tests/test_provider_batch.py +0 -604
- metergraph-0.5.0/tests/test_public_api_surface.py +0 -50
- metergraph-0.5.0/tests/test_real_client_integration.py +0 -327
- metergraph-0.5.0/tests/test_repository_aware_ingest.py +0 -221
- metergraph-0.5.0/tests/test_sdk.py +0 -1465
- metergraph-0.5.0/tests/test_seam_reality.py +0 -35
- metergraph-0.5.0/tests/test_session_manager.py +0 -319
- metergraph-0.5.0/tests/test_writer_session.py +0 -142
- {metergraph-0.5.0 → metergraph-0.6.2}/setup.cfg +0 -0
- {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_batch_first.py +0 -0
- {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_config.py +0 -0
- {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_failure_log.py +0 -0
- {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_provider_batch.py +0 -0
- {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_template.py +0 -0
- {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_track.py +0 -0
- {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph.egg-info/dependency_links.txt +0 -0
- {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
prune tests
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: metergraph
|
|
3
|
-
Version: 0.
|
|
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,32 +13,43 @@ 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
|
|
19
|
+
Requires-Dist: build>=1; extra == "dev"
|
|
17
20
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
18
|
-
Requires-Dist: openai
|
|
19
|
-
Requires-Dist: anthropic
|
|
21
|
+
Requires-Dist: openai<3,>=2.50.0; extra == "dev"
|
|
22
|
+
Requires-Dist: anthropic<1,>=0.40; extra == "dev"
|
|
20
23
|
Requires-Dist: google-genai>=1; extra == "dev"
|
|
24
|
+
Requires-Dist: opentelemetry-sdk>=1.30; extra == "dev"
|
|
21
25
|
|
|
22
26
|
# metergraph (Python)
|
|
23
27
|
|
|
24
28
|
Zero-runtime-dependency capture for OpenAI, Anthropic, Gemini, and Python
|
|
25
29
|
Vercel AI Gateway clients.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
Initialize Metergraph once, then wrap each provider client. `init()` reads the
|
|
31
|
+
token and other omitted options from the environment.
|
|
32
|
+
|
|
33
|
+
Initialization is process-wide. The first `init()` configuration remains
|
|
34
|
+
active; later explicit calls are ignored and produce one generic warning
|
|
35
|
+
without option names, token values, or other secrets.
|
|
29
36
|
|
|
30
37
|
```python
|
|
31
38
|
import metergraph
|
|
32
39
|
from openai import OpenAI
|
|
33
40
|
|
|
41
|
+
metergraph.init(repository="owner/repository")
|
|
34
42
|
# Anthropic() and google-genai's genai.Client() wrap the same way.
|
|
35
43
|
client = metergraph.wrap(OpenAI())
|
|
36
|
-
metergraph.set_session("ticket-123")
|
|
37
44
|
|
|
38
|
-
with metergraph.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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=[...])
|
|
42
53
|
|
|
43
54
|
# Emit this after the user-visible task resolves. It shares the bounded async
|
|
44
55
|
# transport and contains no prompt or output content.
|
|
@@ -52,6 +63,14 @@ metergraph.record_outcome(
|
|
|
52
63
|
)
|
|
53
64
|
```
|
|
54
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
|
+
|
|
55
74
|
Vercel's supported Python surface is AI Gateway through the OpenAI or
|
|
56
75
|
Anthropic SDK. Point either client at the public gateway and `wrap()` detects
|
|
57
76
|
it automatically:
|
|
@@ -61,6 +80,7 @@ import os
|
|
|
61
80
|
import metergraph
|
|
62
81
|
from openai import OpenAI
|
|
63
82
|
|
|
83
|
+
metergraph.init(repository="owner/repository")
|
|
64
84
|
gateway = metergraph.wrap(OpenAI(
|
|
65
85
|
api_key=os.getenv("AI_GATEWAY_API_KEY") or os.getenv("VERCEL_OIDC_TOKEN"),
|
|
66
86
|
base_url="https://ai-gateway.vercel.sh/v1",
|
|
@@ -77,29 +97,72 @@ async, streaming, tool calls, and OpenAI Responses API calls are captured. Use
|
|
|
77
97
|
`metergraph.wrap(client, provider="vercel")` only when a compatible client is
|
|
78
98
|
behind a custom gateway URL that cannot be detected automatically.
|
|
79
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
|
+
|
|
80
131
|
Configuration:
|
|
81
132
|
|
|
82
133
|
- `METERGRAPH_APP_TOKEN` — required bearer token
|
|
83
134
|
- `METERGRAPH_INGEST_URL` — optional override; defaults to the hosted HTTPS endpoint
|
|
135
|
+
- `METERGRAPH_REPOSITORY` — optional `owner/repository` identity; used by [MeterGraph Bot](https://github.com/apps/metergraph)
|
|
84
136
|
- `METERGRAPH_CAPTURE_TEXT=0` — opt out of content capture globally
|
|
137
|
+
- `METERGRAPH_TEXT_MAX_BYTES` — per-field content limit; defaults to 1 MiB
|
|
85
138
|
- `METERGRAPH_DISABLED=1` — process kill switch
|
|
86
139
|
- `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
|
|
87
140
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
with
|
|
141
|
+
Repository identity enables repository-level attribution and
|
|
142
|
+
[MeterGraph Bot](https://github.com/apps/metergraph). Choose any one of these
|
|
143
|
+
options; each is sufficient on its own:
|
|
144
|
+
|
|
145
|
+
- Pass `repository="owner/repository"` to `metergraph.init()`.
|
|
146
|
+
- Set `METERGRAPH_REPOSITORY=owner/repository` in the environment.
|
|
147
|
+
- Store the identity with the source code in `.metergraph/config.json`:
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
{"repository":"owner/repository"}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Resolution order is the explicit option, environment variable, then
|
|
154
|
+
configuration file. The SDK treats the file as read-only. Without repository
|
|
155
|
+
identity, it warns once and continues capture without repository attribution.
|
|
95
156
|
|
|
96
157
|
Delivery is bounded and off the request path. Queue overflow or a collector
|
|
97
158
|
outage drops capture and increments internal counters; it never changes the
|
|
98
|
-
provider call. Each wire batch is bounded to
|
|
99
|
-
|
|
100
|
-
envelope, including assistant content and tool calls
|
|
159
|
+
provider call. Each wire batch is bounded to 4 MiB after optional gzip.
|
|
160
|
+
By default, Metergraph captures the scrubbed provider request and a normalized
|
|
161
|
+
response envelope, including assistant content and tool calls. Provider
|
|
101
162
|
credentials and transport headers are removed. Request and response are each
|
|
102
|
-
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.
|
|
103
166
|
`capture_text=False` on `route()` or `trace()` overrides the global content
|
|
104
167
|
policy for a sensitive operation. The equivalent initialization option is
|
|
105
168
|
`metergraph.init(capture_text=False)`. The public open-source server continues
|
|
@@ -167,11 +230,13 @@ genai.Client() construction in place, e.g. client = metergraph.wrap(OpenAI()).
|
|
|
167
230
|
OpenAI or Anthropic clients pointed at https://ai-gateway.vercel.sh are Vercel
|
|
168
231
|
AI Gateway clients and are detected automatically; keep their creator/model ID
|
|
169
232
|
and AI_GATEWAY_API_KEY / VERCEL_OIDC_TOKEN configuration unchanged.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
233
|
+
Before wrapping, call metergraph.init(repository="owner/repository") using the
|
|
234
|
+
actual GitHub owner and repository name. METERGRAPH_APP_TOKEN is required; the
|
|
235
|
+
SDK warns and disables capture when it is missing. METERGRAPH_INGEST_URL is
|
|
236
|
+
only for self-hosted servers. Document variable names with placeholders in
|
|
237
|
+
.env.example, and put real values only in deployment configuration. Metergraph
|
|
238
|
+
captures scrubbed provider requests and normalized responses by default for
|
|
239
|
+
the hosted dashboard; use
|
|
175
240
|
METERGRAPH_CAPTURE_TEXT=0 or capture_text=False around sensitive operations.
|
|
176
241
|
Provider credentials and transport headers must never be captured. Capture is
|
|
177
242
|
fail-open, so do not change call sites, arguments, or error handling; sync,
|
|
@@ -2,22 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
Zero-runtime-dependency capture for OpenAI, Anthropic, Gemini, and Python
|
|
4
4
|
Vercel AI Gateway clients.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
Initialize Metergraph once, then wrap each provider client. `init()` reads the
|
|
6
|
+
token and other omitted options from the environment.
|
|
7
|
+
|
|
8
|
+
Initialization is process-wide. The first `init()` configuration remains
|
|
9
|
+
active; later explicit calls are ignored and produce one generic warning
|
|
10
|
+
without option names, token values, or other secrets.
|
|
8
11
|
|
|
9
12
|
```python
|
|
10
13
|
import metergraph
|
|
11
14
|
from openai import OpenAI
|
|
12
15
|
|
|
16
|
+
metergraph.init(repository="owner/repository")
|
|
13
17
|
# Anthropic() and google-genai's genai.Client() wrap the same way.
|
|
14
18
|
client = metergraph.wrap(OpenAI())
|
|
15
|
-
metergraph.set_session("ticket-123")
|
|
16
19
|
|
|
17
|
-
with metergraph.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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=[...])
|
|
21
28
|
|
|
22
29
|
# Emit this after the user-visible task resolves. It shares the bounded async
|
|
23
30
|
# transport and contains no prompt or output content.
|
|
@@ -31,6 +38,14 @@ metergraph.record_outcome(
|
|
|
31
38
|
)
|
|
32
39
|
```
|
|
33
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
|
+
|
|
34
49
|
Vercel's supported Python surface is AI Gateway through the OpenAI or
|
|
35
50
|
Anthropic SDK. Point either client at the public gateway and `wrap()` detects
|
|
36
51
|
it automatically:
|
|
@@ -40,6 +55,7 @@ import os
|
|
|
40
55
|
import metergraph
|
|
41
56
|
from openai import OpenAI
|
|
42
57
|
|
|
58
|
+
metergraph.init(repository="owner/repository")
|
|
43
59
|
gateway = metergraph.wrap(OpenAI(
|
|
44
60
|
api_key=os.getenv("AI_GATEWAY_API_KEY") or os.getenv("VERCEL_OIDC_TOKEN"),
|
|
45
61
|
base_url="https://ai-gateway.vercel.sh/v1",
|
|
@@ -56,29 +72,72 @@ async, streaming, tool calls, and OpenAI Responses API calls are captured. Use
|
|
|
56
72
|
`metergraph.wrap(client, provider="vercel")` only when a compatible client is
|
|
57
73
|
behind a custom gateway URL that cannot be detected automatically.
|
|
58
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
|
+
|
|
59
106
|
Configuration:
|
|
60
107
|
|
|
61
108
|
- `METERGRAPH_APP_TOKEN` — required bearer token
|
|
62
109
|
- `METERGRAPH_INGEST_URL` — optional override; defaults to the hosted HTTPS endpoint
|
|
110
|
+
- `METERGRAPH_REPOSITORY` — optional `owner/repository` identity; used by [MeterGraph Bot](https://github.com/apps/metergraph)
|
|
63
111
|
- `METERGRAPH_CAPTURE_TEXT=0` — opt out of content capture globally
|
|
112
|
+
- `METERGRAPH_TEXT_MAX_BYTES` — per-field content limit; defaults to 1 MiB
|
|
64
113
|
- `METERGRAPH_DISABLED=1` — process kill switch
|
|
65
114
|
- `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
|
|
66
115
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
with
|
|
116
|
+
Repository identity enables repository-level attribution and
|
|
117
|
+
[MeterGraph Bot](https://github.com/apps/metergraph). Choose any one of these
|
|
118
|
+
options; each is sufficient on its own:
|
|
119
|
+
|
|
120
|
+
- Pass `repository="owner/repository"` to `metergraph.init()`.
|
|
121
|
+
- Set `METERGRAPH_REPOSITORY=owner/repository` in the environment.
|
|
122
|
+
- Store the identity with the source code in `.metergraph/config.json`:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{"repository":"owner/repository"}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Resolution order is the explicit option, environment variable, then
|
|
129
|
+
configuration file. The SDK treats the file as read-only. Without repository
|
|
130
|
+
identity, it warns once and continues capture without repository attribution.
|
|
74
131
|
|
|
75
132
|
Delivery is bounded and off the request path. Queue overflow or a collector
|
|
76
133
|
outage drops capture and increments internal counters; it never changes the
|
|
77
|
-
provider call. Each wire batch is bounded to
|
|
78
|
-
|
|
79
|
-
envelope, including assistant content and tool calls
|
|
134
|
+
provider call. Each wire batch is bounded to 4 MiB after optional gzip.
|
|
135
|
+
By default, Metergraph captures the scrubbed provider request and a normalized
|
|
136
|
+
response envelope, including assistant content and tool calls. Provider
|
|
80
137
|
credentials and transport headers are removed. Request and response are each
|
|
81
|
-
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.
|
|
82
141
|
`capture_text=False` on `route()` or `trace()` overrides the global content
|
|
83
142
|
policy for a sensitive operation. The equivalent initialization option is
|
|
84
143
|
`metergraph.init(capture_text=False)`. The public open-source server continues
|
|
@@ -146,11 +205,13 @@ genai.Client() construction in place, e.g. client = metergraph.wrap(OpenAI()).
|
|
|
146
205
|
OpenAI or Anthropic clients pointed at https://ai-gateway.vercel.sh are Vercel
|
|
147
206
|
AI Gateway clients and are detected automatically; keep their creator/model ID
|
|
148
207
|
and AI_GATEWAY_API_KEY / VERCEL_OIDC_TOKEN configuration unchanged.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
208
|
+
Before wrapping, call metergraph.init(repository="owner/repository") using the
|
|
209
|
+
actual GitHub owner and repository name. METERGRAPH_APP_TOKEN is required; the
|
|
210
|
+
SDK warns and disables capture when it is missing. METERGRAPH_INGEST_URL is
|
|
211
|
+
only for self-hosted servers. Document variable names with placeholders in
|
|
212
|
+
.env.example, and put real values only in deployment configuration. Metergraph
|
|
213
|
+
captures scrubbed provider requests and normalized responses by default for
|
|
214
|
+
the hosted dashboard; use
|
|
154
215
|
METERGRAPH_CAPTURE_TEXT=0 or capture_text=False around sensitive operations.
|
|
155
216
|
Provider credentials and transport headers must never be captured. Capture is
|
|
156
217
|
fail-open, so do not change call sites, arguments, or error handling; sync,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "metergraph"
|
|
3
|
-
version = "0.
|
|
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,
|
|
@@ -21,7 +32,7 @@ from ._batch_first import (
|
|
|
21
32
|
LateBatchInfo,
|
|
22
33
|
batch_first,
|
|
23
34
|
)
|
|
24
|
-
from ._repo_config import
|
|
35
|
+
from ._repo_config import RepoConfig, discover_repo_config
|
|
25
36
|
from ._session import SessionManager
|
|
26
37
|
from ._track import track
|
|
27
38
|
from ._transport import Writer
|
|
@@ -36,6 +47,8 @@ _config: ConfigPoller | None = None
|
|
|
36
47
|
_session_manager: SessionManager | None = None
|
|
37
48
|
_initialized = False
|
|
38
49
|
_warned_no_token = False
|
|
50
|
+
_warned_no_repository = False
|
|
51
|
+
_warned_repeated_init = False
|
|
39
52
|
|
|
40
53
|
|
|
41
54
|
def _env_bool(name: str, default: bool) -> bool:
|
|
@@ -45,6 +58,17 @@ def _env_bool(name: str, default: bool) -> bool:
|
|
|
45
58
|
return value.lower() not in {"0", "false", "no", "off"}
|
|
46
59
|
|
|
47
60
|
|
|
61
|
+
def _warn_repeated_init() -> None:
|
|
62
|
+
global _warned_repeated_init
|
|
63
|
+
if _warned_repeated_init:
|
|
64
|
+
return
|
|
65
|
+
_warned_repeated_init = True
|
|
66
|
+
log.warning(
|
|
67
|
+
"Metergraph init() was called more than once; "
|
|
68
|
+
"the first configuration remains active."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
48
72
|
def init(
|
|
49
73
|
*,
|
|
50
74
|
token: str | None = None,
|
|
@@ -52,13 +76,17 @@ def init(
|
|
|
52
76
|
capture_text: bool | None = None,
|
|
53
77
|
redact: Callable[[str, str], str] | None = None,
|
|
54
78
|
app_root: str | None = None,
|
|
79
|
+
repository: str | None = None,
|
|
55
80
|
skip_frames: list[str] | None = None,
|
|
56
81
|
environment: str | None = None,
|
|
57
82
|
disabled: bool | None = None,
|
|
83
|
+
text_max_bytes: int | None = None,
|
|
58
84
|
) -> None:
|
|
59
85
|
"""Initialize capture. This function is idempotent and never raises."""
|
|
60
|
-
global _initialized, _warned_no_token,
|
|
86
|
+
global _initialized, _warned_no_token, _warned_no_repository
|
|
87
|
+
global _writer, _config, _session_manager
|
|
61
88
|
if _initialized:
|
|
89
|
+
_warn_repeated_init()
|
|
62
90
|
return
|
|
63
91
|
if os.getenv("METERGRAPH_DISABLED") == "1" or disabled:
|
|
64
92
|
_initialized = True
|
|
@@ -73,10 +101,23 @@ def init(
|
|
|
73
101
|
"Metergraph capture disabled: token and ingest URL are required"
|
|
74
102
|
)
|
|
75
103
|
return
|
|
76
|
-
_initialized = True
|
|
77
104
|
try:
|
|
78
105
|
app_root_resolved = os.path.realpath(app_root or os.getcwd())
|
|
79
|
-
|
|
106
|
+
repository_value = repository or os.getenv("METERGRAPH_REPOSITORY")
|
|
107
|
+
repo_config = (
|
|
108
|
+
RepoConfig(repository_value.strip(), app_root_resolved)
|
|
109
|
+
if isinstance(repository_value, str)
|
|
110
|
+
and "/" in repository_value.strip()
|
|
111
|
+
else discover_repo_config(app_root_resolved)
|
|
112
|
+
)
|
|
113
|
+
_initialized = True
|
|
114
|
+
if repo_config is None and not _warned_no_repository:
|
|
115
|
+
_warned_no_repository = True
|
|
116
|
+
log.warning(
|
|
117
|
+
"Metergraph repository identity is not configured; set "
|
|
118
|
+
"init(repository='owner/repository'), METERGRAPH_REPOSITORY, "
|
|
119
|
+
"or provide .metergraph/config.json. Continuing with legacy ingestion."
|
|
120
|
+
)
|
|
80
121
|
session = (
|
|
81
122
|
SessionManager(
|
|
82
123
|
token,
|
|
@@ -107,15 +148,14 @@ def init(
|
|
|
107
148
|
repo_root=repo_config.repo_root if repo_config is not None else None,
|
|
108
149
|
skip_frames=tuple(skip_frames or ()),
|
|
109
150
|
environment=environment or os.getenv("METERGRAPH_ENV"),
|
|
110
|
-
text_max_bytes=
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
),
|
|
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
|
+
)
|
|
119
159
|
),
|
|
120
160
|
),
|
|
121
161
|
)
|
|
@@ -150,7 +190,8 @@ def wrap(client: Any, *, provider: str | None = None) -> Any:
|
|
|
150
190
|
force gateway handling for a compatible client with a custom URL. Call
|
|
151
191
|
init(...) first to pass Metergraph options in code.
|
|
152
192
|
"""
|
|
153
|
-
|
|
193
|
+
if not _initialized:
|
|
194
|
+
init()
|
|
154
195
|
return _wrap(client, provider=provider)
|
|
155
196
|
|
|
156
197
|
|
|
@@ -260,14 +301,18 @@ __all__ = [
|
|
|
260
301
|
"BatchFirstResult",
|
|
261
302
|
"LateBatchInfo",
|
|
262
303
|
"batch_first",
|
|
304
|
+
"context",
|
|
263
305
|
"flush",
|
|
264
306
|
"init",
|
|
265
307
|
"model_for",
|
|
266
308
|
"record_outcome",
|
|
267
309
|
"route",
|
|
310
|
+
"session",
|
|
311
|
+
"set_default_tags",
|
|
268
312
|
"set_session",
|
|
269
313
|
"set_tags",
|
|
270
314
|
"shutdown",
|
|
315
|
+
"tags",
|
|
271
316
|
"track",
|
|
272
317
|
"trace",
|
|
273
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):
|