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.
Files changed (38) hide show
  1. metergraph-0.6.2/MANIFEST.in +1 -0
  2. {metergraph-0.5.0 → metergraph-0.6.2}/PKG-INFO +92 -27
  3. {metergraph-0.5.0 → metergraph-0.6.2}/README.md +85 -24
  4. {metergraph-0.5.0 → metergraph-0.6.2}/pyproject.toml +3 -2
  5. {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/__init__.py +61 -16
  6. {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_capture.py +59 -2
  7. {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_context.py +117 -11
  8. metergraph-0.6.2/src/metergraph/_repo_config.py +63 -0
  9. {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_session.py +1 -1
  10. {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_transport.py +1 -1
  11. {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_version.py +1 -1
  12. metergraph-0.6.2/src/metergraph/opentelemetry.py +221 -0
  13. {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph.egg-info/PKG-INFO +92 -27
  14. {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph.egg-info/SOURCES.txt +3 -13
  15. metergraph-0.6.2/src/metergraph.egg-info/requires.txt +11 -0
  16. metergraph-0.5.0/src/metergraph/_repo_config.py +0 -165
  17. metergraph-0.5.0/src/metergraph.egg-info/requires.txt +0 -6
  18. metergraph-0.5.0/tests/test_batch_first.py +0 -595
  19. metergraph-0.5.0/tests/test_capture_repo_root.py +0 -101
  20. metergraph-0.5.0/tests/test_edge_cases.py +0 -556
  21. metergraph-0.5.0/tests/test_init_repo_aware.py +0 -110
  22. metergraph-0.5.0/tests/test_provider_batch.py +0 -604
  23. metergraph-0.5.0/tests/test_public_api_surface.py +0 -50
  24. metergraph-0.5.0/tests/test_real_client_integration.py +0 -327
  25. metergraph-0.5.0/tests/test_repository_aware_ingest.py +0 -221
  26. metergraph-0.5.0/tests/test_sdk.py +0 -1465
  27. metergraph-0.5.0/tests/test_seam_reality.py +0 -35
  28. metergraph-0.5.0/tests/test_session_manager.py +0 -319
  29. metergraph-0.5.0/tests/test_writer_session.py +0 -142
  30. {metergraph-0.5.0 → metergraph-0.6.2}/setup.cfg +0 -0
  31. {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_batch_first.py +0 -0
  32. {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_config.py +0 -0
  33. {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_failure_log.py +0 -0
  34. {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_provider_batch.py +0 -0
  35. {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_template.py +0 -0
  36. {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph/_track.py +0 -0
  37. {metergraph-0.5.0 → metergraph-0.6.2}/src/metergraph.egg-info/dependency_links.txt +0 -0
  38. {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.5.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>=2.50.0; extra == "dev"
19
- Requires-Dist: anthropic>=0.40; extra == "dev"
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
- `wrap()` initializes capture from the environment, so setup is one line per
27
- client; call `metergraph.init(...)` before the first `wrap()` only to pass
28
- options in code.
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.trace("ticket-workflow"):
39
- with metergraph.route("ticket-classifier", unit="answer"):
40
- model = metergraph.model_for("ticket-classifier", default="gpt-4.1-mini")
41
- client.chat.completions.create(model=model, messages=[...])
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
- SDK 0.4 associates traces with their GitHub repository automatically. On the
89
- first `init()` in a Git checkout, it reads the `origin` remote and creates
90
- `.metergraph/config.json` at the repository root if that file is absent.
91
- Commit this non-secret file so production can use repository-aware ingest
92
- without Git metadata. An existing file is authoritative and is never changed
93
- by the SDK. If discovery or creation is unavailable, ingest remains compatible
94
- with protocol v1.
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 512 KiB after optional gzip.
99
- SDK 0.4 captures the scrubbed provider request and a normalized response
100
- envelope, including assistant content and tool calls, by default. Provider
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 100 KiB of UTF-8 with an explicit truncation marker.
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
- wrap() returns the same client and initializes itself from the environment:
171
- METERGRAPH_APP_TOKEN is required (capture is silently off without it) and
172
- METERGRAPH_INGEST_URL is only for self-hosted servers. Add both to
173
- .env.example, and never commit a real token. SDK 0.4 captures scrubbed provider
174
- requests and normalized responses by default for the hosted dashboard; use
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
- `wrap()` initializes capture from the environment, so setup is one line per
6
- client; call `metergraph.init(...)` before the first `wrap()` only to pass
7
- options in code.
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.trace("ticket-workflow"):
18
- with metergraph.route("ticket-classifier", unit="answer"):
19
- model = metergraph.model_for("ticket-classifier", default="gpt-4.1-mini")
20
- client.chat.completions.create(model=model, messages=[...])
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
- SDK 0.4 associates traces with their GitHub repository automatically. On the
68
- first `init()` in a Git checkout, it reads the `origin` remote and creates
69
- `.metergraph/config.json` at the repository root if that file is absent.
70
- Commit this non-secret file so production can use repository-aware ingest
71
- without Git metadata. An existing file is authoritative and is never changed
72
- by the SDK. If discovery or creation is unavailable, ingest remains compatible
73
- with protocol v1.
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 512 KiB after optional gzip.
78
- SDK 0.4 captures the scrubbed provider request and a normalized response
79
- envelope, including assistant content and tool calls, by default. Provider
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 100 KiB of UTF-8 with an explicit truncation marker.
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
- wrap() returns the same client and initializes itself from the environment:
150
- METERGRAPH_APP_TOKEN is required (capture is silently off without it) and
151
- METERGRAPH_INGEST_URL is only for self-hosted servers. Add both to
152
- .env.example, and never commit a real token. SDK 0.4 captures scrubbed provider
153
- requests and normalized responses by default for the hosted dashboard; use
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.5.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
- dev = ["pytest>=8", "openai>=2.50.0", "anthropic>=0.40", "google-genai>=1"]
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 route, set_session, set_tags, snapshot, trace, wrap_executor
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 ensure_repo_config
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, _writer, _config, _session_manager
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
- repo_config = ensure_repo_config(app_root_resolved)
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=min(
111
- 100 * 1024,
112
- max(
113
- 1,
114
- int(
115
- os.getenv(
116
- "METERGRAPH_TEXT_MAX_BYTES", str(100 * 1024)
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
- init()
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 = 100 * 1024
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": bool(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):