metergraph 0.3.2__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {metergraph-0.3.2 → metergraph-0.4.0}/PKG-INFO +11 -3
  2. {metergraph-0.3.2 → metergraph-0.4.0}/README.md +10 -2
  3. {metergraph-0.3.2 → metergraph-0.4.0}/pyproject.toml +1 -1
  4. {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph/__init__.py +25 -3
  5. {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph/_capture.py +17 -2
  6. metergraph-0.4.0/src/metergraph/_repo_config.py +165 -0
  7. metergraph-0.4.0/src/metergraph/_session.py +156 -0
  8. {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph/_transport.py +11 -1
  9. {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph/_version.py +1 -1
  10. {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph.egg-info/PKG-INFO +11 -3
  11. {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph.egg-info/SOURCES.txt +8 -1
  12. metergraph-0.4.0/tests/test_capture_repo_root.py +101 -0
  13. metergraph-0.4.0/tests/test_init_repo_aware.py +110 -0
  14. metergraph-0.4.0/tests/test_repository_aware_ingest.py +221 -0
  15. metergraph-0.4.0/tests/test_session_manager.py +319 -0
  16. metergraph-0.4.0/tests/test_writer_session.py +142 -0
  17. {metergraph-0.3.2 → metergraph-0.4.0}/setup.cfg +0 -0
  18. {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph/_config.py +0 -0
  19. {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph/_context.py +0 -0
  20. {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph/_failure_log.py +0 -0
  21. {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph/_template.py +0 -0
  22. {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph/_track.py +0 -0
  23. {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph.egg-info/dependency_links.txt +0 -0
  24. {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph.egg-info/requires.txt +0 -0
  25. {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph.egg-info/top_level.txt +0 -0
  26. {metergraph-0.3.2 → metergraph-0.4.0}/tests/test_edge_cases.py +0 -0
  27. {metergraph-0.3.2 → metergraph-0.4.0}/tests/test_real_client_integration.py +0 -0
  28. {metergraph-0.3.2 → metergraph-0.4.0}/tests/test_sdk.py +0 -0
  29. {metergraph-0.3.2 → metergraph-0.4.0}/tests/test_seam_reality.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraph
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Fire-and-forget LLM spend capture for Metergraph
5
5
  Author: Pioneer Square Labs
6
6
  License-Expression: Apache-2.0
@@ -85,10 +85,18 @@ Configuration:
85
85
  - `METERGRAPH_DISABLED=1` — process kill switch
86
86
  - `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
87
87
 
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.
95
+
88
96
  Delivery is bounded and off the request path. Queue overflow or a collector
89
97
  outage drops capture and increments internal counters; it never changes the
90
98
  provider call. Each wire batch is bounded to 512 KiB after optional gzip.
91
- SDK 0.3 captures the scrubbed provider request and a normalized response
99
+ SDK 0.4 captures the scrubbed provider request and a normalized response
92
100
  envelope, including assistant content and tool calls, by default. Provider
93
101
  credentials and transport headers are removed. Request and response are each
94
102
  limited to 100 KiB of UTF-8 with an explicit truncation marker.
@@ -133,7 +141,7 @@ and AI_GATEWAY_API_KEY / VERCEL_OIDC_TOKEN configuration unchanged.
133
141
  wrap() returns the same client and initializes itself from the environment:
134
142
  METERGRAPH_APP_TOKEN is required (capture is silently off without it) and
135
143
  METERGRAPH_INGEST_URL is only for self-hosted servers. Add both to
136
- .env.example, and never commit a real token. SDK 0.3 captures scrubbed provider
144
+ .env.example, and never commit a real token. SDK 0.4 captures scrubbed provider
137
145
  requests and normalized responses by default for the hosted dashboard; use
138
146
  METERGRAPH_CAPTURE_TEXT=0 or capture_text=False around sensitive operations.
139
147
  Provider credentials and transport headers must never be captured. Capture is
@@ -64,10 +64,18 @@ Configuration:
64
64
  - `METERGRAPH_DISABLED=1` — process kill switch
65
65
  - `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
66
66
 
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.
74
+
67
75
  Delivery is bounded and off the request path. Queue overflow or a collector
68
76
  outage drops capture and increments internal counters; it never changes the
69
77
  provider call. Each wire batch is bounded to 512 KiB after optional gzip.
70
- SDK 0.3 captures the scrubbed provider request and a normalized response
78
+ SDK 0.4 captures the scrubbed provider request and a normalized response
71
79
  envelope, including assistant content and tool calls, by default. Provider
72
80
  credentials and transport headers are removed. Request and response are each
73
81
  limited to 100 KiB of UTF-8 with an explicit truncation marker.
@@ -112,7 +120,7 @@ and AI_GATEWAY_API_KEY / VERCEL_OIDC_TOKEN configuration unchanged.
112
120
  wrap() returns the same client and initializes itself from the environment:
113
121
  METERGRAPH_APP_TOKEN is required (capture is silently off without it) and
114
122
  METERGRAPH_INGEST_URL is only for self-hosted servers. Add both to
115
- .env.example, and never commit a real token. SDK 0.3 captures scrubbed provider
123
+ .env.example, and never commit a real token. SDK 0.4 captures scrubbed provider
116
124
  requests and normalized responses by default for the hosted dashboard; use
117
125
  METERGRAPH_CAPTURE_TEXT=0 or capture_text=False around sensitive operations.
118
126
  Provider credentials and transport headers must never be captured. Capture is
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "metergraph"
3
- version = "0.3.2"
3
+ version = "0.4.0"
4
4
  description = "Fire-and-forget LLM spend capture for Metergraph"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -14,6 +14,8 @@ from ._capture import Options, Runtime, set_runtime
14
14
  from ._capture import wrap as _wrap
15
15
  from ._config import ConfigPoller
16
16
  from ._context import route, set_session, set_tags, snapshot, trace, wrap_executor
17
+ from ._repo_config import ensure_repo_config
18
+ from ._session import SessionManager
17
19
  from ._track import track
18
20
  from ._transport import Writer
19
21
  from ._version import SDK_VERSION
@@ -24,6 +26,7 @@ DEFAULT_INGEST_URL = "https://d2xus7mp8zdv6t.cloudfront.net"
24
26
  log = logging.getLogger("metergraph")
25
27
  _writer: Writer | None = None
26
28
  _config: ConfigPoller | None = None
29
+ _session_manager: SessionManager | None = None
27
30
  _initialized = False
28
31
  _warned_no_token = False
29
32
 
@@ -47,7 +50,7 @@ def init(
47
50
  disabled: bool | None = None,
48
51
  ) -> None:
49
52
  """Initialize capture. This function is idempotent and never raises."""
50
- global _initialized, _warned_no_token, _writer, _config
53
+ global _initialized, _warned_no_token, _writer, _config, _session_manager
51
54
  if _initialized:
52
55
  return
53
56
  if os.getenv("METERGRAPH_DISABLED") == "1" or disabled:
@@ -65,9 +68,23 @@ def init(
65
68
  return
66
69
  _initialized = True
67
70
  try:
71
+ app_root_resolved = os.path.realpath(app_root or os.getcwd())
72
+ repo_config = ensure_repo_config(app_root_resolved)
73
+ session = (
74
+ SessionManager(
75
+ token,
76
+ ingest_url,
77
+ repository=repo_config.repository,
78
+ sdk_version=SDK_VERSION,
79
+ )
80
+ if repo_config is not None
81
+ else None
82
+ )
83
+ _session_manager = session
68
84
  _writer = Writer(
69
85
  token,
70
86
  ingest_url,
87
+ session=session,
71
88
  queue_size=int(os.getenv("METERGRAPH_QUEUE_SIZE", "2000")),
72
89
  batch_size=int(os.getenv("METERGRAPH_BATCH_SIZE", "100")),
73
90
  flush_seconds=float(os.getenv("METERGRAPH_FLUSH_SECONDS", "5")),
@@ -79,7 +96,8 @@ def init(
79
96
  else capture_text
80
97
  ),
81
98
  redact=redact,
82
- app_root=os.path.realpath(app_root or os.getcwd()),
99
+ app_root=app_root_resolved,
100
+ repo_root=repo_config.repo_root if repo_config is not None else None,
83
101
  skip_frames=tuple(skip_frames or ()),
84
102
  environment=environment or os.getenv("METERGRAPH_ENV"),
85
103
  text_max_bytes=min(
@@ -110,6 +128,7 @@ def init(
110
128
  _writer.shutdown()
111
129
  _writer = None
112
130
  _config = None
131
+ _session_manager = None
113
132
  log.warning(
114
133
  "Metergraph initialization failed; application is running uninstrumented"
115
134
  )
@@ -214,13 +233,16 @@ def flush(timeout: float = 3.0) -> bool:
214
233
 
215
234
 
216
235
  def shutdown() -> None:
217
- global _writer, _config
236
+ global _writer, _config, _session_manager
218
237
  if _config:
219
238
  _config.stop()
220
239
  _config = None
221
240
  if _writer:
222
241
  _writer.shutdown()
223
242
  _writer = None
243
+ if _session_manager:
244
+ _session_manager.stop()
245
+ _session_manager = None
224
246
  set_runtime(None)
225
247
 
226
248
 
@@ -556,10 +556,11 @@ def _tool_events(
556
556
 
557
557
 
558
558
  def _capture_frames(
559
- app_root: str, skip_frames: tuple[str, ...]
559
+ app_root: str, skip_frames: tuple[str, ...], repo_root: str | None = None
560
560
  ) -> tuple[str | None, str | None, list[dict]]:
561
561
  frames: list[dict] = []
562
562
  root = os.path.realpath(app_root)
563
+ repo_root_real = os.path.realpath(repo_root) if repo_root else None
563
564
  frame = sys._getframe(2)
564
565
  while frame is not None and len(frames) < 5:
565
566
  filename = os.path.realpath(frame.f_code.co_filename)
@@ -569,7 +570,19 @@ def _capture_frames(
569
570
  relative = os.path.relpath(filename, root)
570
571
  module = str(Path(relative).with_suffix("")).replace(os.sep, ".")
571
572
  qualname = getattr(frame.f_code, "co_qualname", frame.f_code.co_name)
572
- frames.append({"m": module, "f": qualname, "l": frame.f_lineno})
573
+ entry = {"m": module, "f": qualname, "l": frame.f_lineno}
574
+ try:
575
+ inside_repo = bool(
576
+ repo_root_real
577
+ and os.path.commonpath((filename, repo_root_real)) == repo_root_real
578
+ )
579
+ except ValueError:
580
+ inside_repo = False
581
+ if inside_repo:
582
+ entry["p"] = os.path.relpath(filename, repo_root_real).replace(
583
+ os.sep, "/"
584
+ )
585
+ frames.append(entry)
573
586
  frame = frame.f_back
574
587
  if not frames:
575
588
  return None, None, []
@@ -581,6 +594,7 @@ class Options:
581
594
  capture_text: bool = True
582
595
  redact: Callable[[str, str], str] | None = None
583
596
  app_root: str = os.getcwd()
597
+ repo_root: str | None = None
584
598
  skip_frames: tuple[str, ...] = ()
585
599
  environment: str | None = None
586
600
  text_max_bytes: int = 100 * 1024
@@ -609,6 +623,7 @@ class Runtime:
609
623
  "threading.py",
610
624
  *self.options.skip_frames,
611
625
  ),
626
+ self.options.repo_root,
612
627
  )
613
628
  return CallState(
614
629
  runtime=self,
@@ -0,0 +1,165 @@
1
+ """Repository-aware ingest protocol v2: 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 legacy protocol v1, 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:
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
+ (legacy protocol v1), 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
@@ -0,0 +1,156 @@
1
+ """Repository-aware ingest protocol v2: app-token -> session-token exchange.
2
+
3
+ The app token is sent only on POST /v1/ingest/sessions. The resulting
4
+ session token is cached in memory and reused until it's within
5
+ REFRESH_MARGIN_SECONDS of expiry, at which point the next get_token() call
6
+ transparently re-exchanges it. Exchanges happen lazily on the writer's own
7
+ background delivery thread (never on the caller's request path), so a
8
+ blocking round trip here adds no latency to the customer's LLM call. Every
9
+ failure mode is fail-open: get_token() returns None and callers drop/buffer
10
+ that batch rather than ever sending the app token to /v1/ingest.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import threading
18
+ import time
19
+ import urllib.error
20
+ import urllib.request
21
+ from datetime import datetime, timezone
22
+ from typing import Any
23
+
24
+ from ._failure_log import FailureLogger
25
+
26
+
27
+ DEFAULT_TIMEOUT_SECONDS = 3.0
28
+ DEFAULT_TTL_SECONDS = 300.0
29
+ REFRESH_MARGIN_SECONDS = 30.0
30
+ MAX_BACKOFF_SECONDS = 60.0
31
+
32
+
33
+ def _parse_expires_at(value: Any) -> float | None:
34
+ if not isinstance(value, str):
35
+ return None
36
+ text = value[:-1] + "+00:00" if value.endswith("Z") else value
37
+ try:
38
+ parsed = datetime.fromisoformat(text)
39
+ except ValueError:
40
+ return None
41
+ if parsed.tzinfo is None:
42
+ parsed = parsed.replace(tzinfo=timezone.utc)
43
+ return parsed.timestamp()
44
+
45
+
46
+ class SessionManager:
47
+ def __init__(
48
+ self,
49
+ app_token: str,
50
+ base_url: str,
51
+ *,
52
+ repository: str,
53
+ sdk_version: str,
54
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
55
+ ) -> None:
56
+ self._app_token = app_token
57
+ self._url = f"{base_url.rstrip('/')}/v1/ingest/sessions"
58
+ self._repository = repository
59
+ self._sdk_version = sdk_version
60
+ self._timeout = max(0.1, timeout_seconds)
61
+ self._lock = threading.Lock()
62
+ self._token: str | None = None
63
+ self._expires_at = 0.0
64
+ self._stopped = False
65
+ self._retry_at = 0.0
66
+ self._backoff = 1.0
67
+ self._failure_log = FailureLogger()
68
+ if hasattr(os, "register_at_fork"):
69
+ os.register_at_fork(after_in_child=self._after_fork)
70
+
71
+ def _after_fork(self) -> None:
72
+ # A lock held by another thread at fork stays locked forever in the
73
+ # child. The cached token itself is safe to reuse; only reset the lock.
74
+ self._lock = threading.Lock()
75
+
76
+ def get_token(self) -> str | None:
77
+ with self._lock:
78
+ if self._stopped:
79
+ return None
80
+ if self._token is not None and time.time() < self._expires_at - REFRESH_MARGIN_SECONDS:
81
+ return self._token
82
+ if time.monotonic() < self._retry_at:
83
+ return None
84
+ self._exchange()
85
+ with self._lock:
86
+ return None if self._stopped else self._token
87
+
88
+ def invalidate(self) -> None:
89
+ with self._lock:
90
+ self._token = None
91
+ self._expires_at = 0.0
92
+ self._retry_at = 0.0
93
+ self._backoff = 1.0
94
+
95
+ def stop(self) -> None:
96
+ with self._lock:
97
+ self._stopped = True
98
+ self._token = None
99
+ self._expires_at = 0.0
100
+
101
+ def _exchange(self) -> None:
102
+ body = json.dumps(
103
+ {
104
+ "protocol_version": 2,
105
+ "repository": self._repository,
106
+ "sdk_version": self._sdk_version,
107
+ }
108
+ ).encode()
109
+ request = urllib.request.Request(
110
+ self._url,
111
+ data=body,
112
+ headers={
113
+ "Authorization": f"Bearer {self._app_token}",
114
+ "Content-Type": "application/json",
115
+ },
116
+ method="POST",
117
+ )
118
+ try:
119
+ with urllib.request.urlopen(request, timeout=self._timeout) as response:
120
+ doc = json.loads(response.read())
121
+ except urllib.error.HTTPError as exc:
122
+ self._failure_log.report(
123
+ "session_exchange_error",
124
+ f"session exchange to {self._url} failed with HTTP {exc.code}",
125
+ )
126
+ self._mark_failed()
127
+ return
128
+ except Exception as exc:
129
+ self._failure_log.report(
130
+ "session_exchange_error",
131
+ f"session exchange to {self._url} failed: {type(exc).__name__}: {exc}",
132
+ )
133
+ self._mark_failed()
134
+ return
135
+ token = doc.get("session_token") if isinstance(doc, dict) else None
136
+ if not isinstance(token, str) or not token:
137
+ self._failure_log.report(
138
+ "session_exchange_error",
139
+ f"session exchange to {self._url} returned no session_token",
140
+ )
141
+ self._mark_failed()
142
+ return
143
+ expires_at = _parse_expires_at(doc.get("expires_at")) or (
144
+ time.time() + DEFAULT_TTL_SECONDS
145
+ )
146
+ with self._lock:
147
+ if not self._stopped:
148
+ self._token = token
149
+ self._expires_at = expires_at
150
+ self._retry_at = 0.0
151
+ self._backoff = 1.0
152
+
153
+ def _mark_failed(self) -> None:
154
+ with self._lock:
155
+ self._retry_at = time.monotonic() + self._backoff
156
+ self._backoff = min(self._backoff * 2, MAX_BACKOFF_SECONDS)
@@ -27,11 +27,13 @@ class Writer:
27
27
  token: str,
28
28
  base_url: str,
29
29
  *,
30
+ session: Any | None = None,
30
31
  queue_size: int = 2000,
31
32
  batch_size: int = 100,
32
33
  flush_seconds: float = 5.0,
33
34
  ) -> None:
34
35
  self._token = token
36
+ self._session = session
35
37
  self._url = f"{base_url.rstrip('/')}/v1/ingest"
36
38
  self._queue_size = max(1, queue_size)
37
39
  self._batch_size = max(1, min(batch_size, 1000))
@@ -109,13 +111,17 @@ class Writer:
109
111
  if self._fatal or time.monotonic() < self._retry_at:
110
112
  self._dropped += len(rows)
111
113
  return False
114
+ token = self._session.get_token() if self._session is not None else self._token
115
+ if token is None:
116
+ self._dropped += len(rows)
117
+ return False
112
118
  meta = {"dropped": self._dropped, "transport_errors": self._errors}
113
119
  body = json.dumps(
114
120
  {"schema_version": 1, "rows": rows, "meta": meta},
115
121
  separators=(",", ":"),
116
122
  ).encode()
117
123
  headers = {
118
- "Authorization": f"Bearer {self._token}",
124
+ "Authorization": f"Bearer {token}",
119
125
  "Content-Type": "application/json",
120
126
  "User-Agent": f"metergraph-python/{SDK_VERSION}",
121
127
  }
@@ -142,6 +148,10 @@ class Writer:
142
148
  return True
143
149
  except urllib.error.HTTPError as exc:
144
150
  if exc.code in (401, 403):
151
+ if self._session is not None:
152
+ self._session.invalidate()
153
+ self._dropped += len(rows)
154
+ return False
145
155
  self._fatal = True
146
156
  log.warning(
147
157
  "Metergraph authentication failed; capture disabled for this process"
@@ -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.3.2"
11
+ SDK_VERSION = "0.4.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraph
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Fire-and-forget LLM spend capture for Metergraph
5
5
  Author: Pioneer Square Labs
6
6
  License-Expression: Apache-2.0
@@ -85,10 +85,18 @@ Configuration:
85
85
  - `METERGRAPH_DISABLED=1` — process kill switch
86
86
  - `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
87
87
 
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.
95
+
88
96
  Delivery is bounded and off the request path. Queue overflow or a collector
89
97
  outage drops capture and increments internal counters; it never changes the
90
98
  provider call. Each wire batch is bounded to 512 KiB after optional gzip.
91
- SDK 0.3 captures the scrubbed provider request and a normalized response
99
+ SDK 0.4 captures the scrubbed provider request and a normalized response
92
100
  envelope, including assistant content and tool calls, by default. Provider
93
101
  credentials and transport headers are removed. Request and response are each
94
102
  limited to 100 KiB of UTF-8 with an explicit truncation marker.
@@ -133,7 +141,7 @@ and AI_GATEWAY_API_KEY / VERCEL_OIDC_TOKEN configuration unchanged.
133
141
  wrap() returns the same client and initializes itself from the environment:
134
142
  METERGRAPH_APP_TOKEN is required (capture is silently off without it) and
135
143
  METERGRAPH_INGEST_URL is only for self-hosted servers. Add both to
136
- .env.example, and never commit a real token. SDK 0.3 captures scrubbed provider
144
+ .env.example, and never commit a real token. SDK 0.4 captures scrubbed provider
137
145
  requests and normalized responses by default for the hosted dashboard; use
138
146
  METERGRAPH_CAPTURE_TEXT=0 or capture_text=False around sensitive operations.
139
147
  Provider credentials and transport headers must never be captured. Capture is
@@ -5,6 +5,8 @@ src/metergraph/_capture.py
5
5
  src/metergraph/_config.py
6
6
  src/metergraph/_context.py
7
7
  src/metergraph/_failure_log.py
8
+ src/metergraph/_repo_config.py
9
+ src/metergraph/_session.py
8
10
  src/metergraph/_template.py
9
11
  src/metergraph/_track.py
10
12
  src/metergraph/_transport.py
@@ -14,7 +16,12 @@ src/metergraph.egg-info/SOURCES.txt
14
16
  src/metergraph.egg-info/dependency_links.txt
15
17
  src/metergraph.egg-info/requires.txt
16
18
  src/metergraph.egg-info/top_level.txt
19
+ tests/test_capture_repo_root.py
17
20
  tests/test_edge_cases.py
21
+ tests/test_init_repo_aware.py
18
22
  tests/test_real_client_integration.py
23
+ tests/test_repository_aware_ingest.py
19
24
  tests/test_sdk.py
20
- tests/test_seam_reality.py
25
+ tests/test_seam_reality.py
26
+ tests/test_session_manager.py
27
+ tests/test_writer_session.py