runtime-labs-sdk 0.1.0__py3-none-any.whl
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.
- runtime_labs/__init__.py +6 -0
- runtime_labs/binding.py +204 -0
- runtime_labs/bootstrap.py +60 -0
- runtime_labs/config.py +42 -0
- runtime_labs/correlation.py +64 -0
- runtime_labs/export.py +109 -0
- runtime_labs/hooks.py +263 -0
- runtime_labs/payloads.py +147 -0
- runtime_labs/propagation.py +47 -0
- runtime_labs/sdk.py +235 -0
- runtime_labs/semconv.py +64 -0
- runtime_labs_sdk-0.1.0.dist-info/METADATA +382 -0
- runtime_labs_sdk-0.1.0.dist-info/RECORD +14 -0
- runtime_labs_sdk-0.1.0.dist-info/WHEEL +4 -0
runtime_labs/__init__.py
ADDED
runtime_labs/binding.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Conservative, immutable source indexes. This module never imports application code."""
|
|
2
|
+
|
|
3
|
+
import dis
|
|
4
|
+
import hashlib
|
|
5
|
+
import marshal
|
|
6
|
+
import sys
|
|
7
|
+
import warnings
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
from threading import RLock
|
|
11
|
+
from types import CodeType, MappingProxyType
|
|
12
|
+
|
|
13
|
+
from deployment_contracts import DeploymentManifest
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Entity:
|
|
18
|
+
node_id: str
|
|
19
|
+
kind: str
|
|
20
|
+
qualified_symbol: str
|
|
21
|
+
path: str
|
|
22
|
+
source_digest: str
|
|
23
|
+
start: tuple[int, int]
|
|
24
|
+
end: tuple[int, int]
|
|
25
|
+
provider: str | None
|
|
26
|
+
operation: str | None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalized_code(code):
|
|
30
|
+
"""Keep constants, closures, flags and positions; only checkout filenames differ."""
|
|
31
|
+
return code.replace(
|
|
32
|
+
co_filename="<verified-source>",
|
|
33
|
+
co_consts=tuple(normalized_code(c) if isinstance(c, CodeType) else c
|
|
34
|
+
for c in code.co_consts),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def fingerprint(code):
|
|
39
|
+
return hashlib.sha256(marshal.dumps(normalized_code(code))).digest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def codes(code):
|
|
43
|
+
yield code
|
|
44
|
+
for constant in code.co_consts:
|
|
45
|
+
if isinstance(constant, CodeType):
|
|
46
|
+
yield from codes(constant)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def call_position(frame):
|
|
50
|
+
# Include inline CACHE instructions: suspended sync calls and async SEND/RESUME
|
|
51
|
+
# positions then retain the exact UTF-8 byte range, even for same-line calls.
|
|
52
|
+
instructions = [i for i in dis.get_instructions(frame.f_code, show_caches=True)
|
|
53
|
+
if i.offset <= frame.f_lasti]
|
|
54
|
+
instruction = next((i for i in reversed(instructions) if i.opname in {
|
|
55
|
+
"CALL", "CALL_KW", "CALL_FUNCTION_EX",
|
|
56
|
+
}), None)
|
|
57
|
+
p = instruction.positions if instruction else None
|
|
58
|
+
if p and None not in (p.lineno, p.col_offset, p.end_lineno, p.end_col_offset):
|
|
59
|
+
return (p.lineno, p.col_offset), (p.end_lineno, p.end_col_offset)
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SourceIndex:
|
|
64
|
+
def __init__(self, path, diagnose, *, manifest=None):
|
|
65
|
+
self._diagnose = diagnose
|
|
66
|
+
self._lock = RLock()
|
|
67
|
+
self._compiled = {} # Bounded by files from the manifest, not request count.
|
|
68
|
+
self._json = None
|
|
69
|
+
entities = ()
|
|
70
|
+
if path is None and manifest is None:
|
|
71
|
+
diagnose("manifest_missing")
|
|
72
|
+
else:
|
|
73
|
+
try:
|
|
74
|
+
manifest = (DeploymentManifest.model_validate(manifest.model_dump())
|
|
75
|
+
if manifest is not None else
|
|
76
|
+
DeploymentManifest.model_validate_json(path.read_bytes()))
|
|
77
|
+
self._json = manifest.model_dump_json()
|
|
78
|
+
entities = tuple(Entity(
|
|
79
|
+
e.node_id, e.kind, e.qualified_symbol, e.source.path, e.source.source_digest,
|
|
80
|
+
(e.source.location.start_line, e.source.location.start_col),
|
|
81
|
+
(e.source.location.end_line, e.source.location.end_col),
|
|
82
|
+
e.provider, e.operation,
|
|
83
|
+
) for e in manifest.entities)
|
|
84
|
+
except Exception:
|
|
85
|
+
diagnose("manifest_invalid")
|
|
86
|
+
self.entities = entities
|
|
87
|
+
grouped = {}
|
|
88
|
+
for entity in entities:
|
|
89
|
+
key = (entity.kind, entity.qualified_symbol)
|
|
90
|
+
grouped.setdefault(key, []).append(entity)
|
|
91
|
+
self.by_symbol = MappingProxyType({key: tuple(value) for key, value in grouped.items()})
|
|
92
|
+
self.node_ids = frozenset(e.node_id for e in entities)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def manifest(self):
|
|
96
|
+
"""A detached validated copy; changes cannot affect the attribution index."""
|
|
97
|
+
return DeploymentManifest.model_validate_json(self._json) if self._json else None
|
|
98
|
+
|
|
99
|
+
def candidates(self, code, globals_, kind):
|
|
100
|
+
module = globals_.get("__name__", "")
|
|
101
|
+
qualname = code.co_qualname.replace(".<locals>.", ".")
|
|
102
|
+
suffix = "" if qualname == "<module>" else "." + qualname
|
|
103
|
+
if module == "__main__":
|
|
104
|
+
spec_name = getattr(globals_.get("__spec__"), "name", None)
|
|
105
|
+
if spec_name and spec_name != "__main__":
|
|
106
|
+
module = spec_name
|
|
107
|
+
else:
|
|
108
|
+
# A directly executed script has no import name. Derive it only
|
|
109
|
+
# from complete canonical paths in this manifest, then prove the
|
|
110
|
+
# actual path/digest/code below. Never search by basename.
|
|
111
|
+
return tuple(e for e in self.entities if e.kind == kind
|
|
112
|
+
and e.qualified_symbol == ".".join(
|
|
113
|
+
PurePosixPath(e.path).with_suffix("").parts
|
|
114
|
+
).removesuffix(".__init__") + suffix)
|
|
115
|
+
return self.by_symbol.get((kind, module + suffix), ())
|
|
116
|
+
|
|
117
|
+
def _proof(self, code, globals_, entity):
|
|
118
|
+
actual = Path(code.co_filename).resolve()
|
|
119
|
+
module_file = globals_.get("__file__")
|
|
120
|
+
relative = PurePosixPath(entity.path)
|
|
121
|
+
# Both module identity and every manifest path component must agree. No
|
|
122
|
+
# basename search, checkout paths from scanner metadata, or sys.path guessing.
|
|
123
|
+
if (not module_file or actual != Path(module_file).resolve()
|
|
124
|
+
or actual.parts[-len(relative.parts):] != relative.parts):
|
|
125
|
+
self._diagnose("source_path_mismatch")
|
|
126
|
+
return False
|
|
127
|
+
try:
|
|
128
|
+
raw = actual.read_bytes()
|
|
129
|
+
if hashlib.sha256(raw).hexdigest() != entity.source_digest:
|
|
130
|
+
self._diagnose("source_digest_mismatch")
|
|
131
|
+
return False
|
|
132
|
+
with self._lock:
|
|
133
|
+
expected = self._compiled.get(entity.source_digest)
|
|
134
|
+
if expected is None:
|
|
135
|
+
# compile, never exec: compare loaded code to the verified bytes.
|
|
136
|
+
# Compiler warnings can echo source lines. Verification must
|
|
137
|
+
# never print source bodies or alter application warnings.
|
|
138
|
+
with warnings.catch_warnings(record=True):
|
|
139
|
+
warnings.simplefilter("ignore")
|
|
140
|
+
tree = compile(raw, str(actual), "exec", dont_inherit=True,
|
|
141
|
+
optimize=sys.flags.optimize)
|
|
142
|
+
expected = frozenset(fingerprint(c) for c in codes(tree))
|
|
143
|
+
self._compiled[entity.source_digest] = expected
|
|
144
|
+
if fingerprint(code) not in expected:
|
|
145
|
+
self._diagnose("loaded_code_mismatch")
|
|
146
|
+
return False
|
|
147
|
+
return True
|
|
148
|
+
except Exception:
|
|
149
|
+
self._diagnose("source_unavailable")
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
def match(self, code, globals_, kind, position=None):
|
|
153
|
+
candidates = self.candidates(code, globals_, kind)
|
|
154
|
+
if kind == "ModelCallSite":
|
|
155
|
+
candidates = tuple(e for e in candidates if position == (e.start, e.end))
|
|
156
|
+
elif kind == "Workflow":
|
|
157
|
+
candidates = tuple(e for e in candidates if position is not None
|
|
158
|
+
and e.start <= position[0] < position[1] <= e.end)
|
|
159
|
+
else:
|
|
160
|
+
# Functions can have decorators before the def range.
|
|
161
|
+
candidates = tuple(e for e in candidates if e.start[0] <= code.co_firstlineno
|
|
162
|
+
<= e.end[0])
|
|
163
|
+
if len(candidates) != 1:
|
|
164
|
+
if self.entities:
|
|
165
|
+
self._diagnose("source_ambiguous" if candidates else "source_unmatched")
|
|
166
|
+
return None
|
|
167
|
+
entity = candidates[0]
|
|
168
|
+
return entity if self._proof(code, globals_, entity) else None
|
|
169
|
+
|
|
170
|
+
def frame(self, frame, kind):
|
|
171
|
+
if frame is None:
|
|
172
|
+
return None
|
|
173
|
+
return self.match(frame.f_code, frame.f_globals, kind, call_position(frame))
|
|
174
|
+
|
|
175
|
+
def call_site(self, frame, depth=32):
|
|
176
|
+
"""The model call a provider request belongs to.
|
|
177
|
+
|
|
178
|
+
That is the innermost frame whose current call is a verified call site. A
|
|
179
|
+
wrapper that forwards its caller's prompt is not one, so the walk continues
|
|
180
|
+
to the caller that built it. Frames outside the manifest are skipped quietly;
|
|
181
|
+
only a request no frame accounts for is diagnosed.
|
|
182
|
+
"""
|
|
183
|
+
current = frame
|
|
184
|
+
while current is not None and depth:
|
|
185
|
+
position = call_position(current)
|
|
186
|
+
candidates = tuple(
|
|
187
|
+
e for e in self.candidates(current.f_code, current.f_globals, "ModelCallSite")
|
|
188
|
+
if position == (e.start, e.end)
|
|
189
|
+
)
|
|
190
|
+
if len(candidates) > 1:
|
|
191
|
+
self._diagnose("source_ambiguous")
|
|
192
|
+
return None
|
|
193
|
+
if candidates:
|
|
194
|
+
entity = candidates[0]
|
|
195
|
+
return entity if self._proof(current.f_code, current.f_globals, entity) else None
|
|
196
|
+
current, depth = current.f_back, depth - 1
|
|
197
|
+
if frame is not None and self.entities:
|
|
198
|
+
self._diagnose("source_unmatched")
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
def function(self, function, kind):
|
|
202
|
+
if not hasattr(function, "__code__"):
|
|
203
|
+
return None
|
|
204
|
+
return self.match(function.__code__, function.__globals__, kind)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Fetch a validated manifest at initialization; never persist credentials or remote data."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from deployment_contracts import DeploymentManifest
|
|
9
|
+
from repository_identity import git_identity, normalize_git_url
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class RuntimeManifest:
|
|
14
|
+
manifest: DeploymentManifest
|
|
15
|
+
ingestion_url: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _request_manifest(api_url, api_key, git_url, commit_sha):
|
|
19
|
+
with httpx.Client(timeout=5, follow_redirects=False, trust_env=False) as client:
|
|
20
|
+
response = client.post(
|
|
21
|
+
api_url.rstrip("/") + "/api/sdk/manifest",
|
|
22
|
+
json={"git_url": git_url, "commit_sha": commit_sha},
|
|
23
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
24
|
+
)
|
|
25
|
+
response.raise_for_status()
|
|
26
|
+
return response.json()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fetch_runtime_manifest(config, diagnose):
|
|
30
|
+
try:
|
|
31
|
+
remote, commit = git_identity(Path.cwd())
|
|
32
|
+
except Exception:
|
|
33
|
+
diagnose("repository_identity_unavailable")
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
body = _request_manifest(config.api_url, config.api_key, remote, commit)
|
|
37
|
+
if normalize_git_url(body["git_url"]) != remote:
|
|
38
|
+
raise ValueError("Repository identity mismatch")
|
|
39
|
+
manifest = DeploymentManifest.model_validate(body["manifest"])
|
|
40
|
+
if commit and len(manifest.commit_sha) == 40 and manifest.commit_sha != commit:
|
|
41
|
+
diagnose("manifest_revision_mismatch")
|
|
42
|
+
return None
|
|
43
|
+
# The API returns a relative ingestion route, never an arbitrary credential destination.
|
|
44
|
+
path = body["ingestion_path"]
|
|
45
|
+
if (
|
|
46
|
+
not isinstance(path, str)
|
|
47
|
+
or not path.startswith("/api/repositories/")
|
|
48
|
+
or ":" in path
|
|
49
|
+
or "?" in path
|
|
50
|
+
or "#" in path
|
|
51
|
+
or ".." in path
|
|
52
|
+
):
|
|
53
|
+
raise ValueError("Invalid ingestion path")
|
|
54
|
+
base = config.api_url.rstrip("/")
|
|
55
|
+
if urlsplit(base).scheme not in {"http", "https"}:
|
|
56
|
+
raise ValueError("Invalid API URL")
|
|
57
|
+
return RuntimeManifest(manifest, base + path)
|
|
58
|
+
except Exception:
|
|
59
|
+
diagnose("manifest_fetch_failed")
|
|
60
|
+
return None
|
runtime_labs/config.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Read runtime API settings and explicit offline overrides without environment mutation."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from dotenv import dotenv_values
|
|
8
|
+
|
|
9
|
+
from .correlation import allowlist
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Configuration:
|
|
14
|
+
api_key: str = field(repr=False)
|
|
15
|
+
manifest_path: Path | None
|
|
16
|
+
capture_payloads: bool = True
|
|
17
|
+
api_url: str = "http://127.0.0.1:8000"
|
|
18
|
+
correlation_keys: tuple = field(default_factory=allowlist)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def configuration(api_key: str, capture_payloads: bool = True, api_url: str | None = None,
|
|
22
|
+
correlation_keys=None) -> Configuration:
|
|
23
|
+
cwd = Path.cwd()
|
|
24
|
+
env_file = next((p / ".env" for p in (cwd, *cwd.parents) if (p / ".env").is_file()), None)
|
|
25
|
+
# No interpolation: do not resolve or copy unrelated application/scanner secrets.
|
|
26
|
+
value = os.environ.get("RUNTIME_LABS_MANIFEST_PATH")
|
|
27
|
+
base = cwd
|
|
28
|
+
if value is None and env_file is not None:
|
|
29
|
+
value = dotenv_values(env_file, interpolate=False).get("RUNTIME_LABS_MANIFEST_PATH")
|
|
30
|
+
base = env_file.parent
|
|
31
|
+
path = Path(value).expanduser() if value else None
|
|
32
|
+
if path is not None:
|
|
33
|
+
path = (base / path).resolve()
|
|
34
|
+
settings = dotenv_values(env_file, interpolate=False) if env_file else {}
|
|
35
|
+
endpoint = api_url or os.environ.get("RUNTIME_LABS_API_URL") or settings.get("RUNTIME_LABS_API_URL") or "http://127.0.0.1:8000"
|
|
36
|
+
keys = allowlist(
|
|
37
|
+
correlation_keys,
|
|
38
|
+
os.environ.get("RUNTIME_LABS_CORRELATION_KEYS")
|
|
39
|
+
or settings.get("RUNTIME_LABS_CORRELATION_KEYS")
|
|
40
|
+
or "",
|
|
41
|
+
)
|
|
42
|
+
return Configuration(api_key, path, capture_payloads, endpoint.rstrip("/"), keys)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Record framework correlation identifiers verbatim; meaning is assigned downstream.
|
|
2
|
+
|
|
3
|
+
The SDK never decides what an identifier is (a conversation, a session, a run).
|
|
4
|
+
Each framework adapter says where that framework keeps run-scoped identifiers, and
|
|
5
|
+
only allowlisted keys are copied, because applications often keep user IDs and
|
|
6
|
+
secrets in the same configuration. Values become span attributes named
|
|
7
|
+
``runtime_labs.ctx.<framework>.<key>``; Runtime Labs pipelines normalize them.
|
|
8
|
+
|
|
9
|
+
Extend the allowlist without code: ``RUNTIME_LABS_CORRELATION_KEYS=langgraph.tenant,
|
|
10
|
+
langgraph.session`` or ``init(correlation_keys={"langgraph": ["tenant"]})``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from collections.abc import Iterable, Mapping
|
|
15
|
+
from uuid import UUID
|
|
16
|
+
|
|
17
|
+
PREFIX = "runtime_labs.ctx."
|
|
18
|
+
MAX_VALUE_LENGTH = 256
|
|
19
|
+
DEFAULT_KEYS = {
|
|
20
|
+
# LangGraph keeps conversation and checkpoint identity in config["configurable"];
|
|
21
|
+
# run_id is a top-level RunnableConfig key.
|
|
22
|
+
"langgraph": ("thread_id", "checkpoint_id", "checkpoint_ns", "run_id"),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def allowlist(extra: Mapping[str, Iterable[str]] | None = None, environment=None):
|
|
27
|
+
"""Frozen ``((framework, (keys...)), ...)`` so configurations stay comparable."""
|
|
28
|
+
merged = {framework: set(keys) for framework, keys in DEFAULT_KEYS.items()}
|
|
29
|
+
for framework, keys in (extra or {}).items():
|
|
30
|
+
merged.setdefault(framework, set()).update(keys)
|
|
31
|
+
for item in (environment if environment is not None else _environment()).split(","):
|
|
32
|
+
framework, _, key = item.strip().partition(".")
|
|
33
|
+
if framework and key:
|
|
34
|
+
merged.setdefault(framework, set()).add(key)
|
|
35
|
+
return tuple(sorted((framework, tuple(sorted(keys))) for framework, keys in merged.items()))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _environment():
|
|
39
|
+
return os.environ.get("RUNTIME_LABS_CORRELATION_KEYS", "")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def capture(framework: str, values: Mapping, allowed) -> dict[str, str]:
|
|
43
|
+
"""Copy allowlisted scalar identifiers; anything else is ignored, never stringified."""
|
|
44
|
+
keys = dict(allowed).get(framework, ())
|
|
45
|
+
attributes = {}
|
|
46
|
+
for key in keys:
|
|
47
|
+
value = values.get(key)
|
|
48
|
+
if isinstance(value, bool) or value is None:
|
|
49
|
+
continue
|
|
50
|
+
if isinstance(value, (str, int, UUID)):
|
|
51
|
+
text = str(value)
|
|
52
|
+
if text:
|
|
53
|
+
attributes[f"{PREFIX}{framework}.{key}"] = text[:MAX_VALUE_LENGTH]
|
|
54
|
+
return attributes
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def langgraph(config, allowed) -> dict[str, str]:
|
|
58
|
+
if not isinstance(config, Mapping):
|
|
59
|
+
return {}
|
|
60
|
+
configurable = config.get("configurable")
|
|
61
|
+
values = dict(configurable) if isinstance(configurable, Mapping) else {}
|
|
62
|
+
if "run_id" in config:
|
|
63
|
+
values["run_id"] = config["run_id"]
|
|
64
|
+
return capture("langgraph", values, allowed)
|
runtime_labs/export.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Standard OTel exporters for local tracing and eval payloads."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from threading import RLock
|
|
6
|
+
|
|
7
|
+
from opentelemetry.sdk.trace import SpanProcessor
|
|
8
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter, SpanExportResult
|
|
9
|
+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
10
|
+
|
|
11
|
+
SCOPE = "runtime_labs"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LocalExporter(SpanExporter):
|
|
15
|
+
"""One JSON object per finished SDK span; no resource/environment dump."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, stream=None):
|
|
18
|
+
self.stream = stream if stream is not None else sys.stdout
|
|
19
|
+
self._lock = RLock()
|
|
20
|
+
|
|
21
|
+
def export(self, spans):
|
|
22
|
+
with self._lock:
|
|
23
|
+
for span in spans:
|
|
24
|
+
value = {
|
|
25
|
+
"name": span.name,
|
|
26
|
+
"trace_id": format(span.context.trace_id, "032x"),
|
|
27
|
+
"span_id": format(span.context.span_id, "016x"),
|
|
28
|
+
"parent_span_id": format(span.parent.span_id, "016x") if span.parent else None,
|
|
29
|
+
"attributes": dict(span.attributes or {}),
|
|
30
|
+
"status": span.status.status_code.name,
|
|
31
|
+
"start_time_ns": span.start_time,
|
|
32
|
+
"end_time_ns": span.end_time,
|
|
33
|
+
"duration_ns": span.end_time - span.start_time,
|
|
34
|
+
}
|
|
35
|
+
self.stream.write(json.dumps(value) + "\n")
|
|
36
|
+
self.stream.flush()
|
|
37
|
+
return SpanExportResult.SUCCESS
|
|
38
|
+
|
|
39
|
+
def shutdown(self):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
def force_flush(self, timeout_millis=30000):
|
|
43
|
+
self.stream.flush()
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class GuardedExporter(SpanExporter):
|
|
48
|
+
def __init__(self, exporter, diagnose):
|
|
49
|
+
self.exporter = exporter
|
|
50
|
+
self.diagnose = diagnose
|
|
51
|
+
|
|
52
|
+
def export(self, spans):
|
|
53
|
+
try:
|
|
54
|
+
result = self.exporter.export(spans)
|
|
55
|
+
if result != SpanExportResult.SUCCESS:
|
|
56
|
+
self.diagnose("export_failed")
|
|
57
|
+
return result
|
|
58
|
+
except Exception:
|
|
59
|
+
self.diagnose("export_failed")
|
|
60
|
+
return SpanExportResult.FAILURE
|
|
61
|
+
|
|
62
|
+
def force_flush(self, timeout_millis=30000):
|
|
63
|
+
try:
|
|
64
|
+
return self.exporter.force_flush(timeout_millis=timeout_millis)
|
|
65
|
+
except Exception:
|
|
66
|
+
self.diagnose("export_flush_failed")
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
def shutdown(self):
|
|
70
|
+
try:
|
|
71
|
+
self.exporter.shutdown()
|
|
72
|
+
except Exception:
|
|
73
|
+
self.diagnose("export_shutdown_failed")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ScopedProcessor(SpanProcessor):
|
|
77
|
+
"""Reusable attachment on application providers; never exports their other scopes."""
|
|
78
|
+
|
|
79
|
+
def __init__(self):
|
|
80
|
+
self.delegate = None
|
|
81
|
+
self.exporter = None
|
|
82
|
+
self._lock = RLock()
|
|
83
|
+
|
|
84
|
+
def configure(self, exporter, diagnose):
|
|
85
|
+
with self._lock:
|
|
86
|
+
self.exporter = GuardedExporter(exporter, diagnose)
|
|
87
|
+
self.delegate = SimpleSpanProcessor(self.exporter)
|
|
88
|
+
|
|
89
|
+
def on_start(self, span, parent_context=None):
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
def on_end(self, span):
|
|
93
|
+
with self._lock:
|
|
94
|
+
if self.delegate and span.instrumentation_scope.name == SCOPE:
|
|
95
|
+
self.delegate.on_end(span)
|
|
96
|
+
|
|
97
|
+
def force_flush(self, timeout_millis=30000):
|
|
98
|
+
with self._lock:
|
|
99
|
+
return self.exporter.force_flush(timeout_millis) if self.exporter else True
|
|
100
|
+
|
|
101
|
+
def shutdown(self):
|
|
102
|
+
with self._lock:
|
|
103
|
+
delegate, self.delegate = self.delegate, None
|
|
104
|
+
self.exporter = None
|
|
105
|
+
if delegate:
|
|
106
|
+
delegate.shutdown()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
__all__ = ["InMemorySpanExporter", "LocalExporter"]
|