mcp-efficiency-engine 0.1.0 → 0.1.2
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.
- package/.githooks/post-commit +20 -0
- package/.github/workflows/autodocs-sync.yml +2 -15
- package/.github/workflows/release-npm.yml +111 -0
- package/FINAL_USAGE_GUIDE.md +78 -0
- package/README.md +231 -0
- package/autodocs/README.md +6 -0
- package/bin/install-host.js +26 -1
- package/observability/metrics.md +33 -0
- package/observability/scoring-rubric.md +20 -0
- package/orchestrator/wiki/graph_consolidator.py +60 -1
- package/orchestrator/wiki/incremental_engine.py +23 -1
- package/orchestrator/wiki/plugin_manager.py +87 -22
- package/package.json +10 -2
- package/requirements.txt +2 -0
- package/scripts/intake/resolve-routing.py +124 -87
- package/scripts/intake/validate-database-routing.py +13 -4
- package/scripts/intake/validate-rag-routing.py +4 -3
- package/scripts/learning/learning-loop-report.py +28 -10
- package/scripts/ops/bye.ps1 +30 -0
- package/scripts/ops/hi.ps1 +13 -0
- package/scripts/ops/llm-token-probe.py +103 -39
- package/scripts/ops/post-commit-refresh.ps1 +110 -0
- package/scripts/ops/publish-langsmith-kpis.py +302 -0
- package/scripts/ops/telemetry-benchmark.py +184 -0
- package/scripts/setup/install-project-hooks.ps1 +33 -0
- package/scripts/setup/setup-prerequisites.ps1 +42 -15
- package/scripts/wiki/compiler_main.py +108 -76
- package/scripts/wiki/providers/repo_content_provider.py +51 -0
- package/scripts/wiki/validator.py +40 -15
- package/telemetry/__init__.py +4 -0
- package/telemetry/bootstrap.py +47 -0
- package/telemetry/collector/engine.py +261 -0
- package/telemetry/config.json +20 -0
- package/telemetry/config.py +131 -0
- package/telemetry/context/propagation.py +85 -0
- package/telemetry/events/catalog.py +24 -0
- package/telemetry/events/factory.py +42 -0
- package/telemetry/exporters/base.py +16 -0
- package/telemetry/exporters/console/exporter.py +30 -0
- package/telemetry/exporters/json/exporter.py +34 -0
- package/telemetry/exporters/langsmith/exporter.py +415 -0
- package/telemetry/metrics/aggregator.py +60 -0
- package/telemetry/scoring/efficiency.py +68 -0
- package/telemetry/tracing/span.py +43 -0
- package/observability/evals/chat-token-usage-report.json +0 -64
- package/observability/evals/chat-token-usage-report.md +0 -33
- package/observability/evals/iteration-value-report.json +0 -76
- package/observability/evals/iteration-value-report.md +0 -39
- package/observability/evals/learning-loop-report.json +0 -268
- package/observability/evals/learning-loop-report.md +0 -37
- package/observability/evals/repo-intake-eval-cases.md +0 -23
- package/observability/evals/routing-eval-cases.json +0 -68
- package/observability/evals/routing-eval-cases.md +0 -25
- package/observability/evals/routing-eval-report.json +0 -72
- package/observability/logs/copilot-usage-ingest-report.json +0 -13
- package/observability/logs/iteration-metrics.jsonl +0 -75
- package/observability/logs/learning-feedback.jsonl +0 -152
- package/observability/logs/routing-decisions.jsonl +0 -333
- package/observability/logs/session/agent-pipeline-preflight-20260701-004146.log +0 -8
- package/observability/logs/session/agent-pipeline-preflight-20260701-005554.log +0 -8
- package/observability/logs/session/agent-pipeline-preflight-20260701-010351.log +0 -8
- package/observability/logs/session/agent-pipeline-preflight-20260704-145829.log +0 -8
- package/observability/logs/session/agent-pipeline-preflight-20260704-150021.log +0 -8
- package/observability/logs/session/agent-pipeline-preflight-20260704-154328.log +0 -8
- package/observability/logs/session/bye-20260629-221513.json +0 -39
- package/observability/logs/session/bye-20260629-221545.json +0 -59
- package/observability/logs/session/bye-20260629-222007.json +0 -52
- package/observability/logs/session/bye-20260629-222133.json +0 -59
- package/observability/logs/session/bye-20260629-222412.json +0 -45
- package/observability/logs/session/bye-20260629-223224.json +0 -52
- package/observability/logs/session/bye-20260629-224050.json +0 -59
- package/observability/logs/session/bye-20260630-105732.json +0 -24
- package/observability/logs/session/bye-20260630-211536.json +0 -87
- package/observability/logs/session/bye-20260630-223423.json +0 -87
- package/observability/logs/session/bye-20260630-232534.json +0 -87
- package/observability/logs/session/bye-20260701-003638.json +0 -126
- package/observability/logs/session/bye-20260701-003849.json +0 -126
- package/observability/logs/session/bye-20260701-004315.json +0 -138
- package/observability/logs/session/bye-20260701-004559.json +0 -76
- package/observability/logs/session/bye-20260701-005913.json +0 -138
- package/observability/logs/session/bye-20260701-010753.json +0 -138
- package/observability/logs/session/bye-20260703-194106.json +0 -24
- package/observability/logs/session/bye-20260703-233443.json +0 -76
- package/observability/logs/session/bye-20260703-233653.json +0 -76
- package/observability/logs/session/bye-20260704-150317.json +0 -152
- package/observability/logs/session/bye-20260704-150929.json +0 -75
- package/observability/logs/session/bye-20260704-151050.json +0 -75
- package/observability/logs/session/bye-20260705-115458.json +0 -165
- package/observability/logs/session/codegraph-status-20260701-004228.log +0 -39
- package/observability/logs/session/codegraph-status-20260701-004310.log +0 -39
- package/observability/logs/session/codegraph-status-20260701-005637.log +0 -39
- package/observability/logs/session/codegraph-status-20260701-005908.log +0 -39
- package/observability/logs/session/codegraph-status-20260701-010444.log +0 -39
- package/observability/logs/session/codegraph-status-20260701-010747.log +0 -39
- package/observability/logs/session/codegraph-status-20260704-150137.log +0 -26
- package/observability/logs/session/codegraph-status-20260704-150313.log +0 -28
- package/observability/logs/session/codegraph-status-20260704-154445.log +0 -26
- package/observability/logs/session/codegraph-status-20260705-115454.log +0 -27
- package/observability/logs/session/codegraph-sync-20260701-004211.log +0 -88
- package/observability/logs/session/codegraph-sync-20260701-004241.log +0 -82
- package/observability/logs/session/codegraph-sync-20260701-005620.log +0 -89
- package/observability/logs/session/codegraph-sync-20260701-005840.log +0 -88
- package/observability/logs/session/codegraph-sync-20260701-010419.log +0 -93
- package/observability/logs/session/codegraph-sync-20260701-010609.log +0 -90
- package/observability/logs/session/codegraph-sync-20260704-145856.log +0 -70
- package/observability/logs/session/codegraph-sync-20260704-150047.log +0 -53
- package/observability/logs/session/codegraph-sync-20260704-150204.log +0 -44
- package/observability/logs/session/codegraph-sync-20260704-154353.log +0 -26
- package/observability/logs/session/codegraph-sync-20260705-115337.log +0 -32
- package/observability/logs/session/hi-20260629-221459.json +0 -38
- package/observability/logs/session/hi-20260629-221945.json +0 -59
- package/observability/logs/session/hi-20260629-222108.json +0 -66
- package/observability/logs/session/hi-20260629-222352.json +0 -45
- package/observability/logs/session/hi-20260629-225719.json +0 -59
- package/observability/logs/session/hi-20260629-233722.json +0 -52
- package/observability/logs/session/hi-20260629-233913.json +0 -198
- package/observability/logs/session/hi-20260630-110156.json +0 -177
- package/observability/logs/session/hi-20260630-191243.json +0 -184
- package/observability/logs/session/hi-20260630-230050.json +0 -234
- package/observability/logs/session/hi-20260630-230523.json +0 -227
- package/observability/logs/session/hi-20260630-230855.json +0 -227
- package/observability/logs/session/hi-20260630-231638.json +0 -227
- package/observability/logs/session/hi-20260630-231756.json +0 -219
- package/observability/logs/session/hi-20260701-002842.json +0 -234
- package/observability/logs/session/hi-20260701-003458.json +0 -282
- package/observability/logs/session/hi-20260701-004233.json +0 -290
- package/observability/logs/session/hi-20260701-004554.json +0 -236
- package/observability/logs/session/hi-20260701-005641.json +0 -290
- package/observability/logs/session/hi-20260701-010450.json +0 -290
- package/observability/logs/session/hi-20260703-233854.json +0 -312
- package/observability/logs/session/hi-20260703-234448.json +0 -318
- package/observability/logs/session/hi-20260704-145907.json +0 -353
- package/observability/logs/session/hi-20260704-150141.json +0 -392
- package/observability/logs/session/hi-20260704-154448.json +0 -392
- package/observability/logs/session/iteration-value-refresh-20260701-004309.log +0 -3
- package/observability/logs/session/iteration-value-refresh-20260701-005907.log +0 -3
- package/observability/logs/session/iteration-value-refresh-20260701-010747.log +0 -3
- package/observability/logs/session/iteration-value-refresh-20260704-150312.log +0 -3
- package/observability/logs/session/iteration-value-refresh-20260705-115453.log +0 -3
- package/observability/logs/session/learning-loop-refresh-20260701-004309.log +0 -3
- package/observability/logs/session/learning-loop-refresh-20260701-005906.log +0 -3
- package/observability/logs/session/learning-loop-refresh-20260701-010745.log +0 -3
- package/observability/logs/session/learning-loop-refresh-20260704-150309.log +0 -3
- package/observability/logs/session/learning-loop-refresh-20260705-115450.log +0 -3
- package/observability/logs/session/project-notes-refresh-20260701-004227.log +0 -1
- package/observability/logs/session/project-notes-refresh-20260701-004308.log +0 -1
- package/observability/logs/session/project-notes-refresh-20260701-005636.log +0 -1
- package/observability/logs/session/project-notes-refresh-20260701-005906.log +0 -1
- package/observability/logs/session/project-notes-refresh-20260701-010443.log +0 -1
- package/observability/logs/session/project-notes-refresh-20260701-010744.log +0 -1
- package/observability/logs/session/project-notes-refresh-20260704-150133.log +0 -1
- package/observability/logs/session/project-notes-refresh-20260704-150309.log +0 -1
- package/observability/logs/session/project-notes-refresh-20260704-154442.log +0 -1
- package/observability/logs/session/project-notes-refresh-20260705-115449.log +0 -1
- package/observability/logs/session/repo-intake-20260701-004218.log +0 -6
- package/observability/logs/session/repo-intake-20260701-005626.log +0 -6
- package/observability/logs/session/repo-intake-20260701-010433.log +0 -6
- package/observability/logs/session/repo-intake-20260704-150053.log +0 -6
- package/observability/logs/session/repo-intake-20260704-154404.log +0 -6
- package/observability/logs/session/repo-intake-refresh-20260701-004247.log +0 -6
- package/observability/logs/session/repo-intake-refresh-20260701-005847.log +0 -6
- package/observability/logs/session/repo-intake-refresh-20260701-010615.log +0 -6
- package/observability/logs/session/repo-intake-refresh-20260704-150209.log +0 -6
- package/observability/logs/session/repo-intake-refresh-20260705-115342.log +0 -6
- package/observability/logs/session/repo-registry-validation-20260701-004234.log +0 -2
- package/observability/logs/session/repo-registry-validation-20260701-004556.log +0 -2
- package/observability/logs/session/repo-registry-validation-20260701-005833.log +0 -2
- package/observability/logs/session/repo-registry-validation-20260701-010601.log +0 -2
- package/observability/logs/session/repo-registry-validation-20260704-150155.log +0 -2
- package/observability/logs/session/repo-registry-validation-20260705-115323.log +0 -2
- package/observability/logs/session/repomix-refresh-20260701-003823.log +0 -4236
- package/observability/logs/session/repomix-refresh-20260701-004251.log +0 -4275
- package/observability/logs/session/repomix-refresh-20260701-005850.log +0 -4248
- package/observability/logs/session/repomix-refresh-20260701-010619.log +0 -6531
- package/observability/logs/session/repomix-refresh-20260704-150239.log +0 -652
- package/observability/logs/session/repomix-refresh-20260705-115419.log +0 -178
- package/observability/logs/session/routing-evals-20260701-004221.log +0 -2
- package/observability/logs/session/routing-evals-20260701-004235.log +0 -2
- package/observability/logs/session/routing-evals-20260701-005629.log +0 -2
- package/observability/logs/session/routing-evals-20260701-005834.log +0 -2
- package/observability/logs/session/routing-evals-20260701-010437.log +0 -2
- package/observability/logs/session/routing-evals-20260701-010603.log +0 -2
- package/observability/logs/session/routing-evals-20260704-150125.log +0 -2
- package/observability/logs/session/routing-evals-20260704-150157.log +0 -2
- package/observability/logs/session/routing-evals-20260704-154434.log +0 -2
- package/observability/logs/session/routing-evals-20260705-115327.log +0 -2
- package/observability/logs/session/sibling-discovery-refresh-20260701-004250.log +0 -3
- package/observability/logs/session/sibling-discovery-refresh-20260701-005850.log +0 -3
- package/observability/logs/session/sibling-discovery-refresh-20260701-010618.log +0 -3
- package/observability/logs/session/sibling-discovery-refresh-20260704-150238.log +0 -3
- package/observability/logs/session/sibling-discovery-refresh-20260705-115418.log +0 -3
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from contextlib import AbstractContextManager
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from telemetry.context.propagation import (
|
|
10
|
+
TelemetryContext,
|
|
11
|
+
child_context,
|
|
12
|
+
get_current_context,
|
|
13
|
+
new_execution_context,
|
|
14
|
+
reset_current_context,
|
|
15
|
+
set_current_context,
|
|
16
|
+
)
|
|
17
|
+
from telemetry.events.catalog import CANONICAL_EVENTS
|
|
18
|
+
from telemetry.events.factory import make_event
|
|
19
|
+
from telemetry.metrics.aggregator import MetricsAggregator
|
|
20
|
+
from telemetry.scoring.efficiency import compute_efficiency_score
|
|
21
|
+
from telemetry.tracing.span import SpanRecord
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def utc_now() -> str:
|
|
25
|
+
return datetime.now(timezone.utc).isoformat()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class _ExecutionScope(AbstractContextManager[TelemetryContext]):
|
|
30
|
+
collector: "TelemetryCollector"
|
|
31
|
+
context: TelemetryContext
|
|
32
|
+
token: Any
|
|
33
|
+
operation: str
|
|
34
|
+
start_ts: str
|
|
35
|
+
|
|
36
|
+
def __enter__(self) -> TelemetryContext:
|
|
37
|
+
self.collector.record_event("ExecutionStarted", {"operation": self.operation, "started_at": self.start_ts})
|
|
38
|
+
return self.context
|
|
39
|
+
|
|
40
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
41
|
+
if exc is not None:
|
|
42
|
+
self.collector.record_event("ExceptionThrown", {"operation": self.operation, "error": str(exc)}, level="ERROR")
|
|
43
|
+
self.collector.metrics.bump("error_count")
|
|
44
|
+
self.collector.metrics.snapshot.execution_time_ms = max(
|
|
45
|
+
0.0,
|
|
46
|
+
(datetime.fromisoformat(utc_now()) - datetime.fromisoformat(self.start_ts)).total_seconds() * 1000.0,
|
|
47
|
+
)
|
|
48
|
+
self.collector.record_metric("efficiency_score", compute_efficiency_score(self.collector.metrics.to_dict()), unit="score")
|
|
49
|
+
self.collector.record_event("ExecutionFinished", {"operation": self.operation, "finished_at": utc_now()})
|
|
50
|
+
self.collector.flush()
|
|
51
|
+
reset_current_context(self.token)
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class _SpanScope(AbstractContextManager[SpanRecord]):
|
|
57
|
+
collector: "TelemetryCollector"
|
|
58
|
+
span: SpanRecord
|
|
59
|
+
token: Any
|
|
60
|
+
|
|
61
|
+
def __enter__(self) -> SpanRecord:
|
|
62
|
+
self.collector.record_event(
|
|
63
|
+
"SpanStarted",
|
|
64
|
+
{
|
|
65
|
+
"name": self.span.name,
|
|
66
|
+
"span_id": self.span.span_id,
|
|
67
|
+
"parent_span_id": self.span.parent_span_id,
|
|
68
|
+
"kind": self.span.kind,
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
return self.span
|
|
72
|
+
|
|
73
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
74
|
+
if exc is not None:
|
|
75
|
+
self.span.end(status="ERROR", error=str(exc))
|
|
76
|
+
self.collector.metrics.bump("error_count")
|
|
77
|
+
self.collector.record_event("ExceptionThrown", {"span": self.span.name, "error": str(exc)}, level="ERROR")
|
|
78
|
+
else:
|
|
79
|
+
self.span.end(status="OK")
|
|
80
|
+
|
|
81
|
+
if self.span.duration_ms is not None:
|
|
82
|
+
self.collector.metrics.add_duration(self.span.duration_ms)
|
|
83
|
+
|
|
84
|
+
self.collector._emit(
|
|
85
|
+
make_event(
|
|
86
|
+
event_name="SpanFinished",
|
|
87
|
+
event_type="span",
|
|
88
|
+
context=get_current_context(),
|
|
89
|
+
payload={
|
|
90
|
+
"name": self.span.name,
|
|
91
|
+
"kind": self.span.kind,
|
|
92
|
+
"span_id": self.span.span_id,
|
|
93
|
+
"parent_span_id": self.span.parent_span_id,
|
|
94
|
+
"trace_id": self.span.trace_id,
|
|
95
|
+
"execution_id": self.span.execution_id,
|
|
96
|
+
"started_at": self.span.started_at,
|
|
97
|
+
"ended_at": self.span.ended_at,
|
|
98
|
+
"duration_ms": self.span.duration_ms,
|
|
99
|
+
"status": self.span.status,
|
|
100
|
+
"attributes": self.span.attributes,
|
|
101
|
+
"events": self.span.events,
|
|
102
|
+
"error": self.span.error,
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
reset_current_context(self.token)
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TelemetryCollector:
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
*,
|
|
114
|
+
exporters: list[Any],
|
|
115
|
+
enabled: bool = True,
|
|
116
|
+
batch_size: int = 25,
|
|
117
|
+
) -> None:
|
|
118
|
+
self.enabled = bool(enabled)
|
|
119
|
+
self.exporters = exporters
|
|
120
|
+
self.batch_size = max(1, int(batch_size))
|
|
121
|
+
self.queue: list[dict[str, Any]] = []
|
|
122
|
+
self.metrics = MetricsAggregator()
|
|
123
|
+
self.warning_buffer: list[str] = []
|
|
124
|
+
|
|
125
|
+
def start_execution(
|
|
126
|
+
self,
|
|
127
|
+
*,
|
|
128
|
+
operation: str,
|
|
129
|
+
request_id: str | None = None,
|
|
130
|
+
session_id: str = "default-session",
|
|
131
|
+
correlation_id: str | None = None,
|
|
132
|
+
execution_id: str | None = None,
|
|
133
|
+
user: str | None = None,
|
|
134
|
+
provider: str | None = None,
|
|
135
|
+
model: str | None = None,
|
|
136
|
+
) -> AbstractContextManager[TelemetryContext]:
|
|
137
|
+
request = request_id or str(uuid.uuid4())
|
|
138
|
+
correlation = correlation_id or request
|
|
139
|
+
execution = execution_id or str(uuid.uuid4())
|
|
140
|
+
context = new_execution_context(
|
|
141
|
+
request_id=request,
|
|
142
|
+
session_id=session_id,
|
|
143
|
+
correlation_id=correlation,
|
|
144
|
+
execution_id=execution,
|
|
145
|
+
user=user,
|
|
146
|
+
provider=provider,
|
|
147
|
+
model=model,
|
|
148
|
+
)
|
|
149
|
+
token = set_current_context(context)
|
|
150
|
+
return _ExecutionScope(self, context, token, operation, utc_now())
|
|
151
|
+
|
|
152
|
+
def start_span(
|
|
153
|
+
self,
|
|
154
|
+
*,
|
|
155
|
+
name: str,
|
|
156
|
+
kind: str = "INTERNAL",
|
|
157
|
+
attributes: dict[str, Any] | None = None,
|
|
158
|
+
) -> AbstractContextManager[SpanRecord]:
|
|
159
|
+
current = get_current_context()
|
|
160
|
+
if current is None:
|
|
161
|
+
current = new_execution_context(
|
|
162
|
+
request_id=str(uuid.uuid4()),
|
|
163
|
+
session_id="implicit-session",
|
|
164
|
+
correlation_id=str(uuid.uuid4()),
|
|
165
|
+
execution_id=str(uuid.uuid4()),
|
|
166
|
+
)
|
|
167
|
+
token = set_current_context(current)
|
|
168
|
+
else:
|
|
169
|
+
child = child_context(current)
|
|
170
|
+
token = set_current_context(child)
|
|
171
|
+
current = child
|
|
172
|
+
|
|
173
|
+
span = SpanRecord(
|
|
174
|
+
name=name,
|
|
175
|
+
kind=kind,
|
|
176
|
+
span_id=current.span_id,
|
|
177
|
+
parent_span_id=current.parent_span_id,
|
|
178
|
+
trace_id=current.trace_id,
|
|
179
|
+
execution_id=current.execution_id,
|
|
180
|
+
started_at=utc_now(),
|
|
181
|
+
attributes=attributes or {},
|
|
182
|
+
)
|
|
183
|
+
return _SpanScope(self, span, token)
|
|
184
|
+
|
|
185
|
+
def record_event(self, event_name: str, payload: dict[str, Any] | None = None, *, level: str = "INFO") -> None:
|
|
186
|
+
canonical = event_name if event_name in CANONICAL_EVENTS else "WarningGenerated"
|
|
187
|
+
if canonical == "WarningGenerated" and event_name not in CANONICAL_EVENTS:
|
|
188
|
+
payload = {"original_event": event_name, **(payload or {})}
|
|
189
|
+
if level.upper() == "WARNING":
|
|
190
|
+
self.metrics.bump("warning_count")
|
|
191
|
+
self._emit(make_event(event_name=canonical, event_type="event", context=get_current_context(), payload=payload, level=level.upper()))
|
|
192
|
+
|
|
193
|
+
def record_metric(self, metric_name: str, value: float, *, unit: str = "count", tags: dict[str, Any] | None = None) -> None:
|
|
194
|
+
payload = {"name": metric_name, "value": value, "unit": unit, "tags": tags or {}}
|
|
195
|
+
if metric_name == "estimated_cost":
|
|
196
|
+
self.metrics.add_cost(float(value))
|
|
197
|
+
self._emit(make_event(event_name="MetricCalculated", event_type="metric", context=get_current_context(), payload=payload))
|
|
198
|
+
|
|
199
|
+
def record_usage(self, *, input_tokens: int, output_tokens: int, estimated_cost_usd: float) -> None:
|
|
200
|
+
self.metrics.add_tokens(input_tokens, output_tokens)
|
|
201
|
+
self.metrics.add_cost(estimated_cost_usd)
|
|
202
|
+
self.record_metric("tokens_input", float(input_tokens), unit="tokens")
|
|
203
|
+
self.record_metric("tokens_output", float(output_tokens), unit="tokens")
|
|
204
|
+
self.record_metric("total_tokens", float(input_tokens + output_tokens), unit="tokens")
|
|
205
|
+
self.record_metric("estimated_cost", float(estimated_cost_usd), unit="usd")
|
|
206
|
+
|
|
207
|
+
def _emit(self, record: dict[str, Any]) -> None:
|
|
208
|
+
if not self.enabled:
|
|
209
|
+
return
|
|
210
|
+
self.queue.append(record)
|
|
211
|
+
if len(self.queue) >= self.batch_size:
|
|
212
|
+
self.flush()
|
|
213
|
+
|
|
214
|
+
def flush(self) -> None:
|
|
215
|
+
if not self.enabled or not self.queue:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
batch = self.queue[:]
|
|
219
|
+
self.queue = []
|
|
220
|
+
for exporter in self.exporters:
|
|
221
|
+
try:
|
|
222
|
+
exporter_started = make_event(
|
|
223
|
+
event_name="ExporterStarted",
|
|
224
|
+
event_type="event",
|
|
225
|
+
context=get_current_context(),
|
|
226
|
+
payload={"exporter": exporter.name},
|
|
227
|
+
)
|
|
228
|
+
batch_with_started = [exporter_started, *batch]
|
|
229
|
+
exporter.export(batch_with_started)
|
|
230
|
+
exporter.flush()
|
|
231
|
+
exporter_finished = make_event(
|
|
232
|
+
event_name="ExporterFinished",
|
|
233
|
+
event_type="event",
|
|
234
|
+
context=get_current_context(),
|
|
235
|
+
payload={"exporter": exporter.name, "success": True},
|
|
236
|
+
)
|
|
237
|
+
exporter.export([exporter_finished])
|
|
238
|
+
except Exception as exc:
|
|
239
|
+
self.warning_buffer.append(f"exporter_failed:{exporter.name}:{exc}")
|
|
240
|
+
warning = make_event(
|
|
241
|
+
event_name="WarningGenerated",
|
|
242
|
+
event_type="event",
|
|
243
|
+
context=get_current_context(),
|
|
244
|
+
payload={"warning": "exporter_failed", "exporter": exporter.name, "error": str(exc)},
|
|
245
|
+
level="WARNING",
|
|
246
|
+
)
|
|
247
|
+
for backup in self.exporters:
|
|
248
|
+
if backup.name == exporter.name:
|
|
249
|
+
continue
|
|
250
|
+
try:
|
|
251
|
+
backup.export([warning])
|
|
252
|
+
except Exception:
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
def shutdown(self) -> None:
|
|
256
|
+
self.flush()
|
|
257
|
+
for exporter in self.exporters:
|
|
258
|
+
try:
|
|
259
|
+
exporter.shutdown()
|
|
260
|
+
except Exception:
|
|
261
|
+
continue
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"telemetry": {
|
|
3
|
+
"enabled": true,
|
|
4
|
+
"batch_size": 100,
|
|
5
|
+
"telemetry_dir": ".telemetry",
|
|
6
|
+
"exporters": [
|
|
7
|
+
"console",
|
|
8
|
+
"json"
|
|
9
|
+
]
|
|
10
|
+
},
|
|
11
|
+
"langsmith": {
|
|
12
|
+
"enabled": false,
|
|
13
|
+
"api_key": "",
|
|
14
|
+
"project": "",
|
|
15
|
+
"endpoint": "",
|
|
16
|
+
"high_signal_only": true,
|
|
17
|
+
"min_span_duration_ms": 100,
|
|
18
|
+
"emit_execution_summary": true
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _parse_bool(value: str | bool | None, default: bool) -> bool:
|
|
11
|
+
if isinstance(value, bool):
|
|
12
|
+
return value
|
|
13
|
+
if value is None:
|
|
14
|
+
return default
|
|
15
|
+
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parse_float(value: str | float | int | None, default: float) -> float:
|
|
19
|
+
if isinstance(value, (int, float)):
|
|
20
|
+
return float(value)
|
|
21
|
+
if value is None:
|
|
22
|
+
return default
|
|
23
|
+
try:
|
|
24
|
+
return float(str(value).strip())
|
|
25
|
+
except Exception:
|
|
26
|
+
return default
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class LangSmithConfig:
|
|
31
|
+
enabled: bool = False
|
|
32
|
+
api_key: str = ""
|
|
33
|
+
project: str = ""
|
|
34
|
+
endpoint: str = ""
|
|
35
|
+
workspace_id: str = ""
|
|
36
|
+
high_signal_only: bool = True
|
|
37
|
+
min_span_duration_ms: float = 100.0
|
|
38
|
+
emit_execution_summary: bool = True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class TelemetryConfig:
|
|
43
|
+
enabled: bool = True
|
|
44
|
+
exporters: list[str] = field(default_factory=lambda: ["console", "json"])
|
|
45
|
+
batch_size: int = 25
|
|
46
|
+
telemetry_dir: str = ".telemetry"
|
|
47
|
+
langsmith: LangSmithConfig = field(default_factory=LangSmithConfig)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _load_json_file(path: Path) -> dict[str, Any]:
|
|
51
|
+
if not path.exists():
|
|
52
|
+
return {}
|
|
53
|
+
try:
|
|
54
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
55
|
+
except Exception:
|
|
56
|
+
return {}
|
|
57
|
+
return payload if isinstance(payload, dict) else {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load_dotenv_file(path: Path) -> dict[str, str]:
|
|
61
|
+
if not path.exists():
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
values: dict[str, str] = {}
|
|
65
|
+
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
|
66
|
+
line = raw_line.strip()
|
|
67
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
key, value = line.split("=", 1)
|
|
71
|
+
env_key = key.strip()
|
|
72
|
+
if not env_key:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
env_value = value.strip()
|
|
76
|
+
if len(env_value) >= 2 and env_value[0] == env_value[-1] and env_value[0] in {'"', "'"}:
|
|
77
|
+
env_value = env_value[1:-1]
|
|
78
|
+
values[env_key] = env_value
|
|
79
|
+
|
|
80
|
+
return values
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_config(repo_root: Path) -> TelemetryConfig:
|
|
84
|
+
raw = _load_json_file(repo_root / "telemetry" / "config.json")
|
|
85
|
+
dotenv = _load_dotenv_file(repo_root / ".env")
|
|
86
|
+
telemetry_raw = raw.get("telemetry", {}) if isinstance(raw.get("telemetry"), dict) else {}
|
|
87
|
+
langsmith_raw = raw.get("langsmith", {}) if isinstance(raw.get("langsmith"), dict) else {}
|
|
88
|
+
|
|
89
|
+
def _get_env(name: str, default: str | None = None) -> str | None:
|
|
90
|
+
if name in os.environ:
|
|
91
|
+
value = os.environ.get(name)
|
|
92
|
+
return None if value is None else str(value)
|
|
93
|
+
return dotenv.get(name, default)
|
|
94
|
+
|
|
95
|
+
exporters = telemetry_raw.get("exporters", ["console", "json"])
|
|
96
|
+
if not isinstance(exporters, list):
|
|
97
|
+
exporters = ["console", "json"]
|
|
98
|
+
|
|
99
|
+
env_exporters = (_get_env("TELEMETRY_EXPORTERS", "") or "").strip()
|
|
100
|
+
if env_exporters:
|
|
101
|
+
exporters = [item.strip().lower() for item in env_exporters.split(",") if item.strip()]
|
|
102
|
+
|
|
103
|
+
cfg = TelemetryConfig(
|
|
104
|
+
enabled=_parse_bool(_get_env("TELEMETRY_ENABLED"), _parse_bool(telemetry_raw.get("enabled"), True)),
|
|
105
|
+
exporters=[str(item).lower() for item in exporters],
|
|
106
|
+
batch_size=max(1, int((_get_env("TELEMETRY_BATCH_SIZE", str(telemetry_raw.get("batch_size", 25))) or str(telemetry_raw.get("batch_size", 25))))),
|
|
107
|
+
telemetry_dir=str((_get_env("TELEMETRY_DIR", str(telemetry_raw.get("telemetry_dir", ".telemetry"))) or str(telemetry_raw.get("telemetry_dir", ".telemetry")))),
|
|
108
|
+
langsmith=LangSmithConfig(
|
|
109
|
+
enabled=_parse_bool(
|
|
110
|
+
_get_env("LANGSMITH_ENABLED", _get_env("LANGSMITH_TRACING")),
|
|
111
|
+
_parse_bool(langsmith_raw.get("enabled"), False),
|
|
112
|
+
),
|
|
113
|
+
api_key=str((_get_env("LANGSMITH_API_KEY", str(langsmith_raw.get("api_key", ""))) or str(langsmith_raw.get("api_key", "")))),
|
|
114
|
+
project=str((_get_env("LANGSMITH_PROJECT", str(langsmith_raw.get("project", ""))) or str(langsmith_raw.get("project", "")))),
|
|
115
|
+
endpoint=str((_get_env("LANGSMITH_ENDPOINT", str(langsmith_raw.get("endpoint", ""))) or str(langsmith_raw.get("endpoint", "")))),
|
|
116
|
+
workspace_id=str((_get_env("LANGSMITH_WORKSPACE_ID", str(langsmith_raw.get("workspace_id", ""))) or str(langsmith_raw.get("workspace_id", "")))),
|
|
117
|
+
high_signal_only=_parse_bool(
|
|
118
|
+
_get_env("LANGSMITH_HIGH_SIGNAL_ONLY"),
|
|
119
|
+
_parse_bool(langsmith_raw.get("high_signal_only"), True),
|
|
120
|
+
),
|
|
121
|
+
min_span_duration_ms=_parse_float(
|
|
122
|
+
_get_env("LANGSMITH_MIN_SPAN_DURATION_MS"),
|
|
123
|
+
_parse_float(langsmith_raw.get("min_span_duration_ms"), 100.0),
|
|
124
|
+
),
|
|
125
|
+
emit_execution_summary=_parse_bool(
|
|
126
|
+
_get_env("LANGSMITH_EMIT_EXECUTION_SUMMARY"),
|
|
127
|
+
_parse_bool(langsmith_raw.get("emit_execution_summary"), True),
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
return cfg
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextvars
|
|
4
|
+
import secrets
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def utc_now() -> str:
|
|
10
|
+
return datetime.now(timezone.utc).isoformat()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def new_trace_id() -> str:
|
|
14
|
+
# 16 bytes -> 32 lowercase hex chars, aligned with OTel expectations.
|
|
15
|
+
return secrets.token_hex(16)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def new_span_id() -> str:
|
|
19
|
+
# 8 bytes -> 16 lowercase hex chars, aligned with OTel expectations.
|
|
20
|
+
return secrets.token_hex(8)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class TelemetryContext:
|
|
25
|
+
execution_id: str
|
|
26
|
+
trace_id: str
|
|
27
|
+
span_id: str
|
|
28
|
+
parent_span_id: str | None
|
|
29
|
+
request_id: str
|
|
30
|
+
session_id: str
|
|
31
|
+
correlation_id: str
|
|
32
|
+
timestamp: str
|
|
33
|
+
user: str | None = None
|
|
34
|
+
provider: str | None = None
|
|
35
|
+
model: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_context_var: contextvars.ContextVar[TelemetryContext | None] = contextvars.ContextVar("telemetry_context", default=None)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_current_context() -> TelemetryContext | None:
|
|
42
|
+
return _context_var.get()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def set_current_context(context: TelemetryContext | None) -> contextvars.Token[TelemetryContext | None]:
|
|
46
|
+
return _context_var.set(context)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def reset_current_context(token: contextvars.Token[TelemetryContext | None]) -> None:
|
|
50
|
+
_context_var.reset(token)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def new_execution_context(
|
|
54
|
+
*,
|
|
55
|
+
request_id: str,
|
|
56
|
+
session_id: str,
|
|
57
|
+
correlation_id: str,
|
|
58
|
+
execution_id: str,
|
|
59
|
+
user: str | None = None,
|
|
60
|
+
provider: str | None = None,
|
|
61
|
+
model: str | None = None,
|
|
62
|
+
) -> TelemetryContext:
|
|
63
|
+
root_span_id = new_span_id()
|
|
64
|
+
return TelemetryContext(
|
|
65
|
+
execution_id=execution_id,
|
|
66
|
+
trace_id=new_trace_id(),
|
|
67
|
+
span_id=root_span_id,
|
|
68
|
+
parent_span_id=None,
|
|
69
|
+
request_id=request_id,
|
|
70
|
+
session_id=session_id,
|
|
71
|
+
correlation_id=correlation_id,
|
|
72
|
+
timestamp=utc_now(),
|
|
73
|
+
user=user,
|
|
74
|
+
provider=provider,
|
|
75
|
+
model=model,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def child_context(parent: TelemetryContext) -> TelemetryContext:
|
|
80
|
+
return replace(
|
|
81
|
+
parent,
|
|
82
|
+
span_id=new_span_id(),
|
|
83
|
+
parent_span_id=parent.span_id,
|
|
84
|
+
timestamp=utc_now(),
|
|
85
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
CANONICAL_EVENTS = {
|
|
2
|
+
"ExecutionStarted",
|
|
3
|
+
"ExecutionFinished",
|
|
4
|
+
"ToolStarted",
|
|
5
|
+
"ToolFinished",
|
|
6
|
+
"PromptGenerated",
|
|
7
|
+
"PromptOptimized",
|
|
8
|
+
"ContextOptimized",
|
|
9
|
+
"CacheHit",
|
|
10
|
+
"CacheMiss",
|
|
11
|
+
"LLMRequest",
|
|
12
|
+
"LLMResponse",
|
|
13
|
+
"ProviderRequest",
|
|
14
|
+
"ProviderResponse",
|
|
15
|
+
"ExporterStarted",
|
|
16
|
+
"ExporterFinished",
|
|
17
|
+
"ExceptionThrown",
|
|
18
|
+
"WarningGenerated",
|
|
19
|
+
"MetricCalculated",
|
|
20
|
+
"KnowledgeGenerated",
|
|
21
|
+
"SpanStarted",
|
|
22
|
+
"SpanFinished",
|
|
23
|
+
"RoutingResolved",
|
|
24
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from telemetry.context.propagation import TelemetryContext
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def utc_now() -> str:
|
|
10
|
+
return datetime.now(timezone.utc).isoformat()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def make_event(
|
|
14
|
+
*,
|
|
15
|
+
event_name: str,
|
|
16
|
+
event_type: str,
|
|
17
|
+
context: TelemetryContext | None,
|
|
18
|
+
payload: dict[str, Any] | None = None,
|
|
19
|
+
level: str = "INFO",
|
|
20
|
+
) -> dict[str, Any]:
|
|
21
|
+
data: dict[str, Any] = {
|
|
22
|
+
"event_schema_version": "1.0.0",
|
|
23
|
+
"timestamp": utc_now(),
|
|
24
|
+
"type": event_type,
|
|
25
|
+
"event_name": event_name,
|
|
26
|
+
"level": level,
|
|
27
|
+
"payload": payload or {},
|
|
28
|
+
}
|
|
29
|
+
if context is not None:
|
|
30
|
+
data["context"] = {
|
|
31
|
+
"execution_id": context.execution_id,
|
|
32
|
+
"trace_id": context.trace_id,
|
|
33
|
+
"span_id": context.span_id,
|
|
34
|
+
"parent_span_id": context.parent_span_id,
|
|
35
|
+
"request_id": context.request_id,
|
|
36
|
+
"session_id": context.session_id,
|
|
37
|
+
"correlation_id": context.correlation_id,
|
|
38
|
+
"user": context.user,
|
|
39
|
+
"provider": context.provider,
|
|
40
|
+
"model": context.model,
|
|
41
|
+
}
|
|
42
|
+
return data
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ITelemetryExporter(Protocol):
|
|
7
|
+
name: str
|
|
8
|
+
|
|
9
|
+
def export(self, records: list[dict]) -> None:
|
|
10
|
+
...
|
|
11
|
+
|
|
12
|
+
def flush(self) -> None:
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
def shutdown(self) -> None:
|
|
16
|
+
...
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ConsoleExporter:
|
|
7
|
+
name = "console"
|
|
8
|
+
|
|
9
|
+
def export(self, records: list[dict]) -> None:
|
|
10
|
+
for record in records:
|
|
11
|
+
event_name = str(record.get("event_name", "unknown"))
|
|
12
|
+
event_type = str(record.get("type", "event"))
|
|
13
|
+
level = str(record.get("level", "INFO"))
|
|
14
|
+
context = record.get("context", {})
|
|
15
|
+
execution_id = context.get("execution_id", "n/a")
|
|
16
|
+
print(f"[telemetry][{level}][{event_type}] {event_name} execution={execution_id}")
|
|
17
|
+
if event_type == "span":
|
|
18
|
+
payload = record.get("payload", {})
|
|
19
|
+
summary = {
|
|
20
|
+
"name": payload.get("name"),
|
|
21
|
+
"duration_ms": payload.get("duration_ms"),
|
|
22
|
+
"status": payload.get("status"),
|
|
23
|
+
}
|
|
24
|
+
print(f"[telemetry][span] {json.dumps(summary, ensure_ascii=False)}")
|
|
25
|
+
|
|
26
|
+
def flush(self) -> None:
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
def shutdown(self) -> None:
|
|
30
|
+
return
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class JsonExporter:
|
|
8
|
+
name = "json"
|
|
9
|
+
|
|
10
|
+
def __init__(self, telemetry_dir: Path) -> None:
|
|
11
|
+
self.telemetry_dir = telemetry_dir
|
|
12
|
+
self.telemetry_dir.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
self.traces_file = self.telemetry_dir / "traces.jsonl"
|
|
14
|
+
self.metrics_file = self.telemetry_dir / "metrics.jsonl"
|
|
15
|
+
|
|
16
|
+
def _append_lines(self, file_path: Path, entries: list[dict]) -> None:
|
|
17
|
+
if not entries:
|
|
18
|
+
return
|
|
19
|
+
with file_path.open("a", encoding="utf-8") as handle:
|
|
20
|
+
for entry in entries:
|
|
21
|
+
handle.write(json.dumps(entry, ensure_ascii=False, separators=(",", ":")))
|
|
22
|
+
handle.write("\n")
|
|
23
|
+
|
|
24
|
+
def export(self, records: list[dict]) -> None:
|
|
25
|
+
traces = [r for r in records if str(r.get("type", "")) in {"event", "span"}]
|
|
26
|
+
metrics = [r for r in records if str(r.get("type", "")) == "metric"]
|
|
27
|
+
self._append_lines(self.traces_file, traces)
|
|
28
|
+
self._append_lines(self.metrics_file, metrics)
|
|
29
|
+
|
|
30
|
+
def flush(self) -> None:
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
def shutdown(self) -> None:
|
|
34
|
+
return
|