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.
- {metergraph-0.3.2 → metergraph-0.4.0}/PKG-INFO +11 -3
- {metergraph-0.3.2 → metergraph-0.4.0}/README.md +10 -2
- {metergraph-0.3.2 → metergraph-0.4.0}/pyproject.toml +1 -1
- {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph/__init__.py +25 -3
- {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph/_capture.py +17 -2
- metergraph-0.4.0/src/metergraph/_repo_config.py +165 -0
- metergraph-0.4.0/src/metergraph/_session.py +156 -0
- {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph/_transport.py +11 -1
- {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph/_version.py +1 -1
- {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph.egg-info/PKG-INFO +11 -3
- {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph.egg-info/SOURCES.txt +8 -1
- metergraph-0.4.0/tests/test_capture_repo_root.py +101 -0
- metergraph-0.4.0/tests/test_init_repo_aware.py +110 -0
- metergraph-0.4.0/tests/test_repository_aware_ingest.py +221 -0
- metergraph-0.4.0/tests/test_session_manager.py +319 -0
- metergraph-0.4.0/tests/test_writer_session.py +142 -0
- {metergraph-0.3.2 → metergraph-0.4.0}/setup.cfg +0 -0
- {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph/_config.py +0 -0
- {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph/_context.py +0 -0
- {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph/_failure_log.py +0 -0
- {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph/_template.py +0 -0
- {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph/_track.py +0 -0
- {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph.egg-info/dependency_links.txt +0 -0
- {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph.egg-info/requires.txt +0 -0
- {metergraph-0.3.2 → metergraph-0.4.0}/src/metergraph.egg-info/top_level.txt +0 -0
- {metergraph-0.3.2 → metergraph-0.4.0}/tests/test_edge_cases.py +0 -0
- {metergraph-0.3.2 → metergraph-0.4.0}/tests/test_real_client_integration.py +0 -0
- {metergraph-0.3.2 → metergraph-0.4.0}/tests/test_sdk.py +0 -0
- {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
|
+
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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=
|
|
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
|
-
|
|
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 {
|
|
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"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: metergraph
|
|
3
|
-
Version: 0.
|
|
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.
|
|
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.
|
|
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
|