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.
Files changed (26) hide show
  1. {metergraph-0.6.0 → metergraph-0.6.2}/PKG-INFO +58 -10
  2. metergraph-0.6.0/src/metergraph.egg-info/PKG-INFO → metergraph-0.6.2/README.md +52 -29
  3. {metergraph-0.6.0 → metergraph-0.6.2}/pyproject.toml +3 -2
  4. {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/__init__.py +26 -11
  5. {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_capture.py +59 -2
  6. {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_context.py +117 -11
  7. metergraph-0.6.2/src/metergraph/_repo_config.py +63 -0
  8. {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_transport.py +1 -1
  9. {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_version.py +1 -1
  10. metergraph-0.6.2/src/metergraph/opentelemetry.py +221 -0
  11. metergraph-0.6.0/README.md → metergraph-0.6.2/src/metergraph.egg-info/PKG-INFO +77 -7
  12. {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph.egg-info/SOURCES.txt +1 -0
  13. metergraph-0.6.2/src/metergraph.egg-info/requires.txt +11 -0
  14. metergraph-0.6.0/src/metergraph/_repo_config.py +0 -165
  15. metergraph-0.6.0/src/metergraph.egg-info/requires.txt +0 -7
  16. {metergraph-0.6.0 → metergraph-0.6.2}/MANIFEST.in +0 -0
  17. {metergraph-0.6.0 → metergraph-0.6.2}/setup.cfg +0 -0
  18. {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_batch_first.py +0 -0
  19. {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_config.py +0 -0
  20. {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_failure_log.py +0 -0
  21. {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_provider_batch.py +0 -0
  22. {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_session.py +0 -0
  23. {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_template.py +0 -0
  24. {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph/_track.py +0 -0
  25. {metergraph-0.6.0 → metergraph-0.6.2}/src/metergraph.egg-info/dependency_links.txt +0 -0
  26. {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.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,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>=2.50.0; extra == "dev"
20
- 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"
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.trace("ticket-workflow"):
44
- with metergraph.route("ticket-classifier", unit="answer"):
45
- model = metergraph.model_for("ticket-classifier", default="gpt-4.1-mini")
46
- 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=[...])
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 512 KiB after optional gzip.
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 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.
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.trace("ticket-workflow"):
44
- with metergraph.route("ticket-classifier", unit="answer"):
45
- model = metergraph.model_for("ticket-classifier", default="gpt-4.1-mini")
46
- 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=[...])
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 512 KiB after optional gzip.
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 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.
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.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 = ["build>=1", "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,
@@ -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=min(
140
- 100 * 1024,
141
- max(
142
- 1,
143
- int(
144
- os.getenv(
145
- "METERGRAPH_TEXT_MAX_BYTES", str(100 * 1024)
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 = 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):
@@ -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
- return _current.get()
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._token: contextvars.Token[CaptureContext] | None = None
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._token = _current.set(
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._token is not None:
82
- _current.reset(self._token)
83
- self._token = None
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._token: contextvars.Token[CaptureContext] | None = None
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._token = _current.set(
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._token is not None:
169
- _current.reset(self._token)
170
- self._token = None
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)
@@ -18,7 +18,7 @@ from ._version import SDK_VERSION
18
18
 
19
19
 
20
20
  log = logging.getLogger("metergraph")
21
- MAX_BATCH_BYTES = 512 * 1024
21
+ MAX_BATCH_BYTES = 4 * 1024 * 1024
22
22
 
23
23
 
24
24
  class Writer:
@@ -8,4 +8,4 @@ import importlib.metadata
8
8
  try:
9
9
  SDK_VERSION = importlib.metadata.version("metergraph")
10
10
  except Exception:
11
- SDK_VERSION = "0.6.0"
11
+ SDK_VERSION = "0.6.2"
@@ -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.trace("ticket-workflow"):
22
- with metergraph.route("ticket-classifier", unit="answer"):
23
- model = metergraph.model_for("ticket-classifier", default="gpt-4.1-mini")
24
- 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=[...])
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 512 KiB after optional gzip.
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 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.
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
@@ -0,0 +1,11 @@
1
+
2
+ [dev]
3
+ build>=1
4
+ pytest>=8
5
+ openai<3,>=2.50.0
6
+ anthropic<1,>=0.40
7
+ google-genai>=1
8
+ opentelemetry-sdk>=1.30
9
+
10
+ [otel]
11
+ opentelemetry-sdk>=1.30
@@ -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
@@ -1,7 +0,0 @@
1
-
2
- [dev]
3
- build>=1
4
- pytest>=8
5
- openai>=2.50.0
6
- anthropic>=0.40
7
- google-genai>=1
File without changes
File without changes