@obsunified/collector 2.0.1
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/LICENSE +21 -0
- package/README.md +60 -0
- package/dist/auth.d.ts +2 -0
- package/dist/auth.js +9 -0
- package/dist/auth.js.map +1 -0
- package/dist/chunk-UGWDOGGH.js +319 -0
- package/dist/chunk-UGWDOGGH.js.map +1 -0
- package/dist/chunk-XKQTVIRE.js +671 -0
- package/dist/chunk-XKQTVIRE.js.map +1 -0
- package/dist/index.d.ts +1197 -0
- package/dist/index.js +17707 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest-auth-HFVO1zIp.d.ts +165 -0
- package/dist/openai-FJYOQ7WO.js +384 -0
- package/dist/openai-FJYOQ7WO.js.map +1 -0
- package/package.json +73 -0
- package/src/ai-parity/ai-parity.acceptance.test.ts +449 -0
- package/src/analyses/derive.ts +642 -0
- package/src/analyses/index.ts +50 -0
- package/src/analyses/investigations.ts +277 -0
- package/src/analyses/tier0.ts +568 -0
- package/src/auth/dashboard-auth.ts +269 -0
- package/src/auth/ingest-auth.ts +147 -0
- package/src/durable-objects/tail-hub.test.ts +32 -0
- package/src/durable-objects/tail-hub.ts +158 -0
- package/src/framework/collector.ts +467 -0
- package/src/framework/env.ts +90 -0
- package/src/framework/logger.ts +81 -0
- package/src/index.ts +211 -0
- package/src/lib/action-aggregates.ts +1031 -0
- package/src/lib/action-identity.ts +106 -0
- package/src/lib/actions-store.ts +397 -0
- package/src/lib/ai-pricing.ts +80 -0
- package/src/lib/ai-store/calls.ts +148 -0
- package/src/lib/ai-store/evaluations.test.ts +47 -0
- package/src/lib/ai-store/evaluations.ts +125 -0
- package/src/lib/ai-store/helpers.ts +37 -0
- package/src/lib/ai-store/retention.ts +17 -0
- package/src/lib/ai-store/sessions.ts +277 -0
- package/src/lib/ai-store/spans.ts +109 -0
- package/src/lib/ai-store/types.ts +112 -0
- package/src/lib/ai-store.ts +76 -0
- package/src/lib/alerts-store.ts +458 -0
- package/src/lib/analyses-runner.ts +439 -0
- package/src/lib/analyses-store.test.ts +243 -0
- package/src/lib/analyses-store.ts +454 -0
- package/src/lib/ask.test.ts +302 -0
- package/src/lib/ask.ts +390 -0
- package/src/lib/blob-store-s3.ts +213 -0
- package/src/lib/blob-store.ts +366 -0
- package/src/lib/eval-cases-store.test.ts +171 -0
- package/src/lib/eval-cases-store.ts +1033 -0
- package/src/lib/evidence-references.test.ts +10 -0
- package/src/lib/evidence-references.ts +684 -0
- package/src/lib/hash.ts +26 -0
- package/src/lib/identity-index/action-lookups.ts +805 -0
- package/src/lib/identity-index/constants.ts +1 -0
- package/src/lib/identity-index/key-lookups.ts +199 -0
- package/src/lib/identity-index/mappers.ts +349 -0
- package/src/lib/identity-index/types.ts +196 -0
- package/src/lib/identity-index/user-lookup.ts +137 -0
- package/src/lib/identity-index.test.ts +624 -0
- package/src/lib/identity-index.ts +85 -0
- package/src/lib/json.ts +39 -0
- package/src/lib/llm.ts +263 -0
- package/src/lib/logs-store.ts +188 -0
- package/src/lib/metrics-store.test.ts +189 -0
- package/src/lib/metrics-store.ts +312 -0
- package/src/lib/narrate-gate.test.ts +260 -0
- package/src/lib/narrate-gate.ts +253 -0
- package/src/lib/openai.test.ts +300 -0
- package/src/lib/openai.ts +512 -0
- package/src/lib/otlp.ts +212 -0
- package/src/lib/parse-pprof.test.ts +64 -0
- package/src/lib/parse-pprof.ts +184 -0
- package/src/lib/payload-capture.ts +22 -0
- package/src/lib/projects-store.ts +207 -0
- package/src/lib/propagation-metric.test.ts +196 -0
- package/src/lib/propagation-metric.ts +175 -0
- package/src/lib/sql-db-dialect.test.ts +22 -0
- package/src/lib/sql-db-postgres.ts +229 -0
- package/src/lib/sql-db.ts +166 -0
- package/src/lib/stage6.test.ts +216 -0
- package/src/lib/store/export.ts +65 -0
- package/src/lib/store/helpers.ts +367 -0
- package/src/lib/store/ingest.ts +68 -0
- package/src/lib/store/issues.ts +158 -0
- package/src/lib/store/overview.ts +127 -0
- package/src/lib/store/retention.ts +10 -0
- package/src/lib/store/service-map.ts +384 -0
- package/src/lib/store/trace-detail.bench.ts +76 -0
- package/src/lib/store/trace-detail.test.ts +410 -0
- package/src/lib/store/trace-detail.ts +427 -0
- package/src/lib/store/trace-queries.test.ts +29 -0
- package/src/lib/store/trace-queries.ts +89 -0
- package/src/lib/store-overview.test.ts +77 -0
- package/src/lib/store.ts +96 -0
- package/src/lib/tail-publisher.ts +64 -0
- package/src/lib/test-utils/mem-sql-db.test.ts +93 -0
- package/src/lib/test-utils/mem-sql-db.ts +86 -0
- package/src/lib/ua-parser.ts +61 -0
- package/src/lib/usage-store.ts +567 -0
- package/src/migrations/001_telemetry_spans.sql +28 -0
- package/src/migrations/002_usage_events.sql +24 -0
- package/src/migrations/003_usage_analytics_columns.sql +13 -0
- package/src/migrations/004_logs.sql +19 -0
- package/src/migrations/005_ai_calls.sql +25 -0
- package/src/migrations/006_user_profiles.sql +11 -0
- package/src/migrations/007_session_replay_metadata.sql +10 -0
- package/src/migrations/008_session_replay_storage_bytes.sql +1 -0
- package/src/migrations/009_projects_and_keys.sql +22 -0
- package/src/migrations/010_signal_project_id.sql +15 -0
- package/src/migrations/011_default_project_seed.sql +3 -0
- package/src/migrations/012_alert_rules.sql +17 -0
- package/src/migrations/013_alert_evaluations.sql +13 -0
- package/src/migrations/014_alert_state.sql +9 -0
- package/src/migrations/015_metrics.sql +58 -0
- package/src/migrations/016_metrics_exp_histogram_summary.sql +5 -0
- package/src/migrations/017_span_dropped_counts.sql +11 -0
- package/src/migrations/018_log_flags.sql +8 -0
- package/src/migrations/019_ai_span_payloads.sql +30 -0
- package/src/migrations/020_ai_span_evaluations.sql +33 -0
- package/src/migrations/021_ai_span_session.sql +13 -0
- package/src/migrations/022_span_log_session_id.sql +13 -0
- package/src/migrations/023_service_map_indexes.sql +32 -0
- package/src/migrations/024_analysis_results.sql +49 -0
- package/src/migrations/025_stage6_autopinning_alerts.sql +38 -0
- package/src/migrations/026_obs_dashboard_project_seed.sql +18 -0
- package/src/migrations/027_identity_propagation.sql +50 -0
- package/src/migrations/028_profile_blobs.sql +57 -0
- package/src/migrations/029_telemetry_sdk_name.sql +20 -0
- package/src/migrations/030_analysis_claim.sql +18 -0
- package/src/migrations/031_agent_action_graph.sql +154 -0
- package/src/migrations/032_payload_capture_flags.sql +8 -0
- package/src/migrations/033_eval_cases.sql +52 -0
- package/src/migrations/034_eval_case_results.sql +23 -0
- package/src/migrations/035_metric_exemplars.sql +33 -0
- package/src/migrations/036_trace_instrumentation_gaps.sql +19 -0
- package/src/migrations/037_eval_runs.sql +37 -0
- package/src/migrations/038_tool_call_evidence.sql +11 -0
- package/src/migrations/039_drop_trace_instrumentation_gaps.sql +11 -0
- package/src/migrations/040_evidence_retrieval_refs.sql +50 -0
- package/src/migrations-postgres/001_telemetry_spans.sql +33 -0
- package/src/migrations-postgres/002_usage_events.sql +24 -0
- package/src/migrations-postgres/003_usage_analytics_columns.sql +13 -0
- package/src/migrations-postgres/004_logs.sql +19 -0
- package/src/migrations-postgres/005_ai_calls.sql +25 -0
- package/src/migrations-postgres/006_user_profiles.sql +11 -0
- package/src/migrations-postgres/007_session_replay_metadata.sql +10 -0
- package/src/migrations-postgres/008_session_replay_storage_bytes.sql +2 -0
- package/src/migrations-postgres/009_projects_and_keys.sql +25 -0
- package/src/migrations-postgres/010_signal_project_id.sql +18 -0
- package/src/migrations-postgres/011_default_project_seed.sql +5 -0
- package/src/migrations-postgres/012_alert_rules.sql +17 -0
- package/src/migrations-postgres/013_alert_evaluations.sql +13 -0
- package/src/migrations-postgres/014_alert_state.sql +9 -0
- package/src/migrations-postgres/015_metrics.sql +47 -0
- package/src/migrations-postgres/016_metrics_exp_histogram_summary.sql +1 -0
- package/src/migrations-postgres/017_span_dropped_counts.sql +4 -0
- package/src/migrations-postgres/018_log_flags.sql +2 -0
- package/src/migrations-postgres/019_ai_span_payloads.sql +18 -0
- package/src/migrations-postgres/020_ai_span_evaluations.sql +21 -0
- package/src/migrations-postgres/021_ai_span_session.sql +7 -0
- package/src/migrations-postgres/022_span_log_session_id.sql +7 -0
- package/src/migrations-postgres/023_service_map_indexes.sql +9 -0
- package/src/migrations-postgres/024_analysis_results.sql +41 -0
- package/src/migrations-postgres/025_stage6_autopinning_alerts.sql +17 -0
- package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +14 -0
- package/src/migrations-postgres/027_identity_propagation.sql +27 -0
- package/src/migrations-postgres/028_profile_blobs.sql +48 -0
- package/src/migrations-postgres/029_telemetry_sdk_name.sql +5 -0
- package/src/migrations-postgres/030_analysis_claim.sql +5 -0
- package/src/migrations-postgres/031_agent_action_graph.sql +190 -0
- package/src/migrations-postgres/032_payload_capture_flags.sql +8 -0
- package/src/migrations-postgres/033_eval_cases.sql +55 -0
- package/src/migrations-postgres/034_eval_case_results.sql +26 -0
- package/src/migrations-postgres/035_metric_exemplars.sql +31 -0
- package/src/migrations-postgres/036_trace_instrumentation_gaps.sql +19 -0
- package/src/migrations-postgres/037_eval_runs.sql +40 -0
- package/src/migrations-postgres/038_tool_call_evidence.sql +11 -0
- package/src/migrations-postgres/039_drop_trace_instrumentation_gaps.sql +11 -0
- package/src/migrations-postgres/040_evidence_retrieval_refs.sql +47 -0
- package/src/migrations-postgres/README.md +40 -0
- package/src/otlp/acceptance.test.ts +373 -0
- package/src/otlp/decode/body.ts +127 -0
- package/src/otlp/decode/logs.ts +96 -0
- package/src/otlp/decode/metrics.ts +402 -0
- package/src/otlp/decode/traces.ts +92 -0
- package/src/otlp/decode/values.ts +150 -0
- package/src/otlp/decode.test.ts +800 -0
- package/src/otlp/decode.ts +11 -0
- package/src/otlp/gen/opentelemetry/proto/collector/logs/v1/logs_service_pb.ts +132 -0
- package/src/otlp/gen/opentelemetry/proto/collector/metrics/v1/metrics_service_pb.ts +158 -0
- package/src/otlp/gen/opentelemetry/proto/collector/trace/v1/trace_service_pb.ts +158 -0
- package/src/otlp/gen/opentelemetry/proto/common/v1/common_pb.ts +289 -0
- package/src/otlp/gen/opentelemetry/proto/logs/v1/logs_pb.ts +397 -0
- package/src/otlp/gen/opentelemetry/proto/metrics/v1/metrics_pb.ts +1329 -0
- package/src/otlp/gen/opentelemetry/proto/resource/v1/resource_pb.ts +86 -0
- package/src/otlp/gen/opentelemetry/proto/trace/v1/trace_pb.ts +710 -0
- package/src/otlp/response.test.ts +158 -0
- package/src/otlp/response.ts +129 -0
- package/src/plugins/_context.ts +15 -0
- package/src/plugins/action-graph-processor/enrichers.ts +45 -0
- package/src/plugins/action-graph-processor/redaction.ts +84 -0
- package/src/plugins/action-graph-processor.test.ts +1023 -0
- package/src/plugins/action-graph-processor.ts +1172 -0
- package/src/plugins/action-routes.test.ts +716 -0
- package/src/plugins/action-routes.ts +343 -0
- package/src/plugins/ai-receiver.ts +254 -0
- package/src/plugins/ai-span-payloads-processor.test.ts +90 -0
- package/src/plugins/ai-span-payloads-processor.ts +180 -0
- package/src/plugins/alerts-evaluator.test.ts +91 -0
- package/src/plugins/alerts-evaluator.ts +328 -0
- package/src/plugins/alerts-routes.ts +101 -0
- package/src/plugins/analyses-routes.ts +138 -0
- package/src/plugins/ask-routes.ts +182 -0
- package/src/plugins/bot-filter.ts +19 -0
- package/src/plugins/connected-routes/manifest.ts +531 -0
- package/src/plugins/connected-routes.test.ts +1791 -0
- package/src/plugins/connected-routes.ts +1103 -0
- package/src/plugins/dashboard-routes.ts +41 -0
- package/src/plugins/default-span-enrichment.ts +92 -0
- package/src/plugins/eval-cases-routes.test.ts +391 -0
- package/src/plugins/eval-cases-routes.ts +529 -0
- package/src/plugins/evidence-retrieval-routes.benchmark.test.ts +245 -0
- package/src/plugins/evidence-retrieval-routes.test.ts +932 -0
- package/src/plugins/evidence-retrieval-routes.ts +2179 -0
- package/src/plugins/gen-ai-normalizer.test.ts +680 -0
- package/src/plugins/gen-ai-normalizer.ts +302 -0
- package/src/plugins/identity-receiver.ts +96 -0
- package/src/plugins/issue-enrichment.ts +132 -0
- package/src/plugins/issue-insights.ts +81 -0
- package/src/plugins/logs-receiver.ts +160 -0
- package/src/plugins/metrics-receiver.ts +91 -0
- package/src/plugins/onboarding-routes.ts +54 -0
- package/src/plugins/otlp-receiver.ts +124 -0
- package/src/plugins/platform-routes.ts +165 -0
- package/src/plugins/profile-routes.ts +468 -0
- package/src/plugins/projects-routes.ts +92 -0
- package/src/plugins/query-routes.ts +220 -0
- package/src/plugins/redaction-processor.ts +132 -0
- package/src/plugins/replay-query-routes.test.ts +38 -0
- package/src/plugins/replay-query-routes.ts +199 -0
- package/src/plugins/replay-receiver.ts +81 -0
- package/src/plugins/tail-routes.test.ts +47 -0
- package/src/plugins/tail-routes.ts +42 -0
- package/src/plugins/timeline-routes.ts +244 -0
- package/src/plugins/ua-enrichment.ts +27 -0
- package/src/plugins/usage-normalization.ts +46 -0
- package/src/plugins/usage-privacy.ts +55 -0
- package/src/plugins/usage-query-routes.ts +102 -0
- package/src/plugins/usage-receiver.ts +129 -0
- package/src/plugins/users-query-routes.ts +78 -0
- package/src/plugins/utm-enrichment.ts +36 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI-observability parity suite.
|
|
3
|
+
*
|
|
4
|
+
* Exercises the live collector end-to-end and asserts the wire contract we
|
|
5
|
+
* need to claim Phoenix/LangSmith-level AI observability. For each dimension
|
|
6
|
+
* the Phoenix docs list, this suite sends a realistic OTLP payload and
|
|
7
|
+
* verifies the collector exposes it through `/internal/ai/*`:
|
|
8
|
+
*
|
|
9
|
+
* 1. Typed OpenInference span kinds (LLM, TOOL, RETRIEVER) flow through
|
|
10
|
+
* telemetry_spans → ai_span_payloads and come back from /ai/spans
|
|
11
|
+
* 2. Payloads (input/output) are stripped from attributes_json and land
|
|
12
|
+
* in the payload side table without truncation
|
|
13
|
+
* 3. gen_ai.* auto-instrumentation attrs are normalized to OpenInference
|
|
14
|
+
* on ingest (no SDK required on the caller)
|
|
15
|
+
* 4. Token counts, cost, and provider metadata are queryable
|
|
16
|
+
* 5. Evaluations post via /v1/ai/evaluations and join back by (trace,span)
|
|
17
|
+
* 6. Sessions group spans across traces under one session.id
|
|
18
|
+
*
|
|
19
|
+
* Skipped unless AI_PARITY_URL is set (the runner script provides it).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, expect, it } from "vitest";
|
|
23
|
+
|
|
24
|
+
declare const process: { env: Record<string, string | undefined> };
|
|
25
|
+
|
|
26
|
+
const BASE = process.env.AI_PARITY_URL;
|
|
27
|
+
|
|
28
|
+
// Helper: build a minimal OTLP trace payload with a single span carrying
|
|
29
|
+
// the given attribute map.
|
|
30
|
+
const buildSpan = (opts: {
|
|
31
|
+
traceId: string;
|
|
32
|
+
spanId: string;
|
|
33
|
+
parentSpanId?: string;
|
|
34
|
+
name: string;
|
|
35
|
+
serviceName: string;
|
|
36
|
+
attributes: Record<string, unknown>;
|
|
37
|
+
startNano: string;
|
|
38
|
+
endNano: string;
|
|
39
|
+
statusCode?: number;
|
|
40
|
+
}) => {
|
|
41
|
+
const kv = (k: string, v: unknown) => {
|
|
42
|
+
if (typeof v === "string") return { key: k, value: { stringValue: v } };
|
|
43
|
+
if (typeof v === "boolean") return { key: k, value: { boolValue: v } };
|
|
44
|
+
if (typeof v === "number")
|
|
45
|
+
return Number.isInteger(v)
|
|
46
|
+
? { key: k, value: { intValue: v } }
|
|
47
|
+
: { key: k, value: { doubleValue: v } };
|
|
48
|
+
return { key: k, value: { stringValue: String(v) } };
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
resourceSpans: [
|
|
52
|
+
{
|
|
53
|
+
resource: { attributes: [kv("service.name", opts.serviceName)] },
|
|
54
|
+
scopeSpans: [
|
|
55
|
+
{
|
|
56
|
+
scope: { name: opts.serviceName },
|
|
57
|
+
spans: [
|
|
58
|
+
{
|
|
59
|
+
traceId: opts.traceId,
|
|
60
|
+
spanId: opts.spanId,
|
|
61
|
+
parentSpanId: opts.parentSpanId,
|
|
62
|
+
name: opts.name,
|
|
63
|
+
kind: 2,
|
|
64
|
+
startTimeUnixNano: opts.startNano,
|
|
65
|
+
endTimeUnixNano: opts.endNano,
|
|
66
|
+
attributes: Object.entries(opts.attributes).map(([k, v]) =>
|
|
67
|
+
kv(k, v),
|
|
68
|
+
),
|
|
69
|
+
status: { code: opts.statusCode ?? 1 },
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const randomHex = (bytes: number): string => {
|
|
80
|
+
const arr = new Uint8Array(bytes);
|
|
81
|
+
crypto.getRandomValues(arr);
|
|
82
|
+
return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const postTrace = async (payload: unknown): Promise<Response> =>
|
|
86
|
+
fetch(`${BASE}/v1/traces`, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "Content-Type": "application/json" },
|
|
89
|
+
body: JSON.stringify(payload),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const nowNano = (): string => (BigInt(Date.now()) * 1_000_000n).toString();
|
|
93
|
+
const agoNano = (ms: number): string =>
|
|
94
|
+
(BigInt(Date.now() - ms) * 1_000_000n).toString();
|
|
95
|
+
|
|
96
|
+
interface AISpan {
|
|
97
|
+
traceId: string;
|
|
98
|
+
spanId: string;
|
|
99
|
+
parentSpanId: string | null;
|
|
100
|
+
spanKind: string;
|
|
101
|
+
spanName: string;
|
|
102
|
+
serviceName: string | null;
|
|
103
|
+
durationMs: number;
|
|
104
|
+
statusCode: number;
|
|
105
|
+
attributes: Record<string, unknown>;
|
|
106
|
+
inputJson: string | null;
|
|
107
|
+
outputJson: string | null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface AISpansResponse {
|
|
111
|
+
spans: AISpan[];
|
|
112
|
+
summary: {
|
|
113
|
+
totalSpans: number;
|
|
114
|
+
byKind: Record<string, number>;
|
|
115
|
+
errorSpans: number;
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe.skipIf(!BASE)("AI observability parity", () => {
|
|
120
|
+
it("1. LLM span with OpenInference attrs round-trips through /ai/spans", async () => {
|
|
121
|
+
const traceId = randomHex(16);
|
|
122
|
+
const spanId = randomHex(8);
|
|
123
|
+
const messages = [
|
|
124
|
+
{ role: "system", content: "You are concise." },
|
|
125
|
+
{ role: "user", content: "Capital of France?" },
|
|
126
|
+
];
|
|
127
|
+
const output = { role: "assistant", content: "Paris." };
|
|
128
|
+
|
|
129
|
+
const payload = buildSpan({
|
|
130
|
+
traceId,
|
|
131
|
+
spanId,
|
|
132
|
+
name: "openai.chat.completions",
|
|
133
|
+
serviceName: "ai-parity",
|
|
134
|
+
startNano: agoNano(1200),
|
|
135
|
+
endNano: nowNano(),
|
|
136
|
+
attributes: {
|
|
137
|
+
"openinference.span.kind": "LLM",
|
|
138
|
+
"llm.model_name": "gpt-4o-mini",
|
|
139
|
+
"llm.provider": "openai",
|
|
140
|
+
"llm.token_count.prompt": 18,
|
|
141
|
+
"llm.token_count.completion": 2,
|
|
142
|
+
"llm.token_count.total": 20,
|
|
143
|
+
"ai.payload.input": JSON.stringify(messages),
|
|
144
|
+
"ai.payload.output": JSON.stringify(output),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const res = await postTrace(payload);
|
|
149
|
+
expect(res.status).toBe(200);
|
|
150
|
+
|
|
151
|
+
// Give the collector a beat — SpanProcessor runs during the ingest,
|
|
152
|
+
// so by the time POST returns the row should exist, but CI is noisy.
|
|
153
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
154
|
+
|
|
155
|
+
const q = await fetch(
|
|
156
|
+
`${BASE}/internal/ai/spans?traceId=${traceId}&hours=1`,
|
|
157
|
+
);
|
|
158
|
+
expect(q.status).toBe(200);
|
|
159
|
+
const body = (await q.json()) as AISpansResponse;
|
|
160
|
+
|
|
161
|
+
const llm = body.spans.find((s) => s.spanId === spanId);
|
|
162
|
+
expect(llm, "LLM span not found in /ai/spans response").toBeTruthy();
|
|
163
|
+
expect(llm?.spanKind).toBe("LLM");
|
|
164
|
+
expect(llm?.attributes["llm.model_name"]).toBe("gpt-4o-mini");
|
|
165
|
+
expect(llm?.attributes["llm.provider"]).toBe("openai");
|
|
166
|
+
expect(llm?.attributes["llm.token_count.prompt"]).toBe(18);
|
|
167
|
+
expect(llm?.attributes["llm.token_count.completion"]).toBe(2);
|
|
168
|
+
|
|
169
|
+
// Payload blobs MUST be stripped from attributes and routed to the
|
|
170
|
+
// side table — Phoenix parity requires input/output be rehydratable
|
|
171
|
+
// for replay.
|
|
172
|
+
expect(llm?.attributes).not.toHaveProperty("ai.payload.input");
|
|
173
|
+
expect(llm?.attributes).not.toHaveProperty("ai.payload.output");
|
|
174
|
+
expect(llm?.inputJson).toBe(JSON.stringify(messages));
|
|
175
|
+
expect(llm?.outputJson).toBe(JSON.stringify(output));
|
|
176
|
+
|
|
177
|
+
// Cost must be auto-computed from the vendor pricing table since we
|
|
178
|
+
// didn't report it directly. gpt-4o-mini is in our table.
|
|
179
|
+
expect(llm?.attributes["llm.cost.total_usd"]).toBeGreaterThan(0);
|
|
180
|
+
expect(llm?.attributes["llm.cost.computed"]).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("2. RETRIEVER span preserves document attributes for RAG debugging", async () => {
|
|
184
|
+
const traceId = randomHex(16);
|
|
185
|
+
const spanId = randomHex(8);
|
|
186
|
+
|
|
187
|
+
const payload = buildSpan({
|
|
188
|
+
traceId,
|
|
189
|
+
spanId,
|
|
190
|
+
name: "knowledge_base.search",
|
|
191
|
+
serviceName: "ai-parity",
|
|
192
|
+
startNano: agoNano(200),
|
|
193
|
+
endNano: nowNano(),
|
|
194
|
+
attributes: {
|
|
195
|
+
"openinference.span.kind": "RETRIEVER",
|
|
196
|
+
"retrieval.query": "capital of France",
|
|
197
|
+
"retrieval.documents.count": 2,
|
|
198
|
+
"retrieval.documents.0.document.id": "doc_001",
|
|
199
|
+
"retrieval.documents.0.document.score": 0.92,
|
|
200
|
+
"retrieval.documents.0.document.content":
|
|
201
|
+
"Paris is the capital of France.",
|
|
202
|
+
"retrieval.documents.1.document.id": "doc_042",
|
|
203
|
+
"retrieval.documents.1.document.score": 0.71,
|
|
204
|
+
"retrieval.documents.1.document.content":
|
|
205
|
+
"France is a country in Western Europe.",
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
await postTrace(payload);
|
|
209
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
210
|
+
|
|
211
|
+
const q = await fetch(
|
|
212
|
+
`${BASE}/internal/ai/spans?traceId=${traceId}&hours=1`,
|
|
213
|
+
);
|
|
214
|
+
const body = (await q.json()) as AISpansResponse;
|
|
215
|
+
const retriever = body.spans.find((s) => s.spanId === spanId);
|
|
216
|
+
|
|
217
|
+
expect(retriever?.spanKind).toBe("RETRIEVER");
|
|
218
|
+
expect(retriever?.attributes["retrieval.query"]).toBe("capital of France");
|
|
219
|
+
expect(retriever?.attributes["retrieval.documents.count"]).toBe(2);
|
|
220
|
+
expect(retriever?.attributes["retrieval.documents.0.document.id"]).toBe(
|
|
221
|
+
"doc_001",
|
|
222
|
+
);
|
|
223
|
+
expect(
|
|
224
|
+
retriever?.attributes["retrieval.documents.0.document.score"],
|
|
225
|
+
).toBeCloseTo(0.92, 2);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("3. TOOL span captures name + parameters + output", async () => {
|
|
229
|
+
const traceId = randomHex(16);
|
|
230
|
+
const spanId = randomHex(8);
|
|
231
|
+
const params = { city: "Tokyo" };
|
|
232
|
+
const result = { city: "Tokyo", tempC: 18, condition: "partly cloudy" };
|
|
233
|
+
|
|
234
|
+
await postTrace(
|
|
235
|
+
buildSpan({
|
|
236
|
+
traceId,
|
|
237
|
+
spanId,
|
|
238
|
+
name: "get_weather",
|
|
239
|
+
serviceName: "ai-parity",
|
|
240
|
+
startNano: agoNano(80),
|
|
241
|
+
endNano: nowNano(),
|
|
242
|
+
attributes: {
|
|
243
|
+
"openinference.span.kind": "TOOL",
|
|
244
|
+
"tool.name": "get_weather",
|
|
245
|
+
"ai.payload.input": JSON.stringify(params),
|
|
246
|
+
"ai.payload.output": JSON.stringify(result),
|
|
247
|
+
},
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
251
|
+
|
|
252
|
+
const q = await fetch(
|
|
253
|
+
`${BASE}/internal/ai/spans?traceId=${traceId}&hours=1`,
|
|
254
|
+
);
|
|
255
|
+
const body = (await q.json()) as AISpansResponse;
|
|
256
|
+
const tool = body.spans.find((s) => s.spanId === spanId);
|
|
257
|
+
|
|
258
|
+
expect(tool?.spanKind).toBe("TOOL");
|
|
259
|
+
expect(tool?.attributes["tool.name"]).toBe("get_weather");
|
|
260
|
+
expect(tool?.inputJson).toBe(JSON.stringify(params));
|
|
261
|
+
expect(tool?.outputJson).toBe(JSON.stringify(result));
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("4. Auto-instrumentation: gen_ai.* attrs normalize to OpenInference", async () => {
|
|
265
|
+
const traceId = randomHex(16);
|
|
266
|
+
const spanId = randomHex(8);
|
|
267
|
+
|
|
268
|
+
// Simulate what the OpenAI SDK or Vercel AI SDK would emit natively —
|
|
269
|
+
// no OpenInference attrs set by the caller.
|
|
270
|
+
await postTrace(
|
|
271
|
+
buildSpan({
|
|
272
|
+
traceId,
|
|
273
|
+
spanId,
|
|
274
|
+
name: "chat",
|
|
275
|
+
serviceName: "ai-parity",
|
|
276
|
+
startNano: agoNano(1000),
|
|
277
|
+
endNano: nowNano(),
|
|
278
|
+
attributes: {
|
|
279
|
+
"gen_ai.system": "openai",
|
|
280
|
+
"gen_ai.operation.name": "chat",
|
|
281
|
+
"gen_ai.request.model": "gpt-4o",
|
|
282
|
+
"gen_ai.response.model": "gpt-4o-2024-11-20",
|
|
283
|
+
"gen_ai.usage.input_tokens": 42,
|
|
284
|
+
"gen_ai.usage.output_tokens": 120,
|
|
285
|
+
"gen_ai.prompt.0.role": "user",
|
|
286
|
+
"gen_ai.prompt.0.content": "hello",
|
|
287
|
+
"gen_ai.completion.0.role": "assistant",
|
|
288
|
+
"gen_ai.completion.0.content": "hi there",
|
|
289
|
+
},
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
293
|
+
|
|
294
|
+
const q = await fetch(
|
|
295
|
+
`${BASE}/internal/ai/spans?traceId=${traceId}&hours=1`,
|
|
296
|
+
);
|
|
297
|
+
const body = (await q.json()) as AISpansResponse;
|
|
298
|
+
const span = body.spans.find((s) => s.spanId === spanId);
|
|
299
|
+
|
|
300
|
+
expect(span, "gen_ai.* span not normalized to OpenInference").toBeTruthy();
|
|
301
|
+
expect(span?.spanKind).toBe("LLM");
|
|
302
|
+
// Normalization: gen_ai.response.model wins (actual served model) over
|
|
303
|
+
// gen_ai.request.model, and maps to llm.model_name.
|
|
304
|
+
expect(span?.attributes["llm.model_name"]).toBe("gpt-4o-2024-11-20");
|
|
305
|
+
expect(span?.attributes["llm.provider"]).toBe("openai");
|
|
306
|
+
expect(span?.attributes["llm.token_count.prompt"]).toBe(42);
|
|
307
|
+
expect(span?.attributes["llm.token_count.completion"]).toBe(120);
|
|
308
|
+
// Indexed prompt/completion arrays get reassembled into input/output
|
|
309
|
+
// payloads and routed to the side table, same as native SDK spans.
|
|
310
|
+
expect(span?.inputJson).toBeTruthy();
|
|
311
|
+
expect(span?.outputJson).toBeTruthy();
|
|
312
|
+
expect(span?.inputJson).toContain("hello");
|
|
313
|
+
expect(span?.outputJson).toContain("hi there");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("5. Evaluation attaches to a span and returns via /ai/evaluations", async () => {
|
|
317
|
+
const traceId = randomHex(16);
|
|
318
|
+
const spanId = randomHex(8);
|
|
319
|
+
|
|
320
|
+
await postTrace(
|
|
321
|
+
buildSpan({
|
|
322
|
+
traceId,
|
|
323
|
+
spanId,
|
|
324
|
+
name: "openai.chat.completions",
|
|
325
|
+
serviceName: "ai-parity",
|
|
326
|
+
startNano: agoNano(500),
|
|
327
|
+
endNano: nowNano(),
|
|
328
|
+
attributes: {
|
|
329
|
+
"openinference.span.kind": "LLM",
|
|
330
|
+
"llm.model_name": "gpt-4o-mini",
|
|
331
|
+
"llm.provider": "openai",
|
|
332
|
+
"ai.payload.input": "[]",
|
|
333
|
+
"ai.payload.output": "{}",
|
|
334
|
+
},
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
338
|
+
|
|
339
|
+
const postEval = await fetch(`${BASE}/v1/ai/evaluations`, {
|
|
340
|
+
method: "POST",
|
|
341
|
+
headers: { "Content-Type": "application/json" },
|
|
342
|
+
body: JSON.stringify({
|
|
343
|
+
evaluations: [
|
|
344
|
+
{
|
|
345
|
+
traceId,
|
|
346
|
+
spanId,
|
|
347
|
+
name: "hallucination",
|
|
348
|
+
score: 0.08,
|
|
349
|
+
label: "grounded",
|
|
350
|
+
explanation: "Answer is consistent with retrieved context.",
|
|
351
|
+
source: "llm_judge",
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
}),
|
|
355
|
+
});
|
|
356
|
+
expect(postEval.status).toBe(202);
|
|
357
|
+
|
|
358
|
+
const list = await fetch(
|
|
359
|
+
`${BASE}/internal/ai/evaluations?traceId=${traceId}`,
|
|
360
|
+
);
|
|
361
|
+
const body = (await list.json()) as {
|
|
362
|
+
evaluations: Array<{
|
|
363
|
+
name: string;
|
|
364
|
+
score: number | null;
|
|
365
|
+
label: string | null;
|
|
366
|
+
source: string;
|
|
367
|
+
spanId: string;
|
|
368
|
+
}>;
|
|
369
|
+
};
|
|
370
|
+
const evalRow = body.evaluations.find((e) => e.spanId === spanId);
|
|
371
|
+
expect(evalRow?.name).toBe("hallucination");
|
|
372
|
+
expect(evalRow?.score).toBeCloseTo(0.08, 4);
|
|
373
|
+
expect(evalRow?.label).toBe("grounded");
|
|
374
|
+
expect(evalRow?.source).toBe("llm_judge");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("6. Sessions group multiple traces under one session.id", async () => {
|
|
378
|
+
const sessionId = `parity-session-${Date.now().toString(36)}`;
|
|
379
|
+
const userId = "parity-user";
|
|
380
|
+
|
|
381
|
+
// Emit three LLM spans across three DIFFERENT trace IDs — only the
|
|
382
|
+
// session.id stamps them together. This is the Phoenix Sessions model.
|
|
383
|
+
for (let i = 0; i < 3; i++) {
|
|
384
|
+
const traceId = randomHex(16);
|
|
385
|
+
const spanId = randomHex(8);
|
|
386
|
+
await postTrace(
|
|
387
|
+
buildSpan({
|
|
388
|
+
traceId,
|
|
389
|
+
spanId,
|
|
390
|
+
name: `turn-${i}`,
|
|
391
|
+
serviceName: "ai-parity",
|
|
392
|
+
startNano: agoNano(500 - i * 100),
|
|
393
|
+
endNano: agoNano(500 - i * 100 - 50),
|
|
394
|
+
attributes: {
|
|
395
|
+
"openinference.span.kind": "LLM",
|
|
396
|
+
"llm.model_name": "gpt-4o-mini",
|
|
397
|
+
"llm.provider": "openai",
|
|
398
|
+
"llm.token_count.prompt": 10 + i,
|
|
399
|
+
"llm.token_count.completion": 5 + i,
|
|
400
|
+
"session.id": sessionId,
|
|
401
|
+
"user.id": userId,
|
|
402
|
+
"ai.payload.input": JSON.stringify([
|
|
403
|
+
{ role: "user", content: `message ${i}` },
|
|
404
|
+
]),
|
|
405
|
+
"ai.payload.output": JSON.stringify({
|
|
406
|
+
role: "assistant",
|
|
407
|
+
content: `reply ${i}`,
|
|
408
|
+
}),
|
|
409
|
+
},
|
|
410
|
+
}),
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
414
|
+
|
|
415
|
+
const detail = await fetch(
|
|
416
|
+
`${BASE}/internal/ai/sessions/${encodeURIComponent(sessionId)}`,
|
|
417
|
+
);
|
|
418
|
+
expect(detail.status).toBe(200);
|
|
419
|
+
const body = (await detail.json()) as {
|
|
420
|
+
sessionId: string;
|
|
421
|
+
userId: string | null;
|
|
422
|
+
spans: Array<{
|
|
423
|
+
spanId: string;
|
|
424
|
+
spanKind: string;
|
|
425
|
+
inputJson: string | null;
|
|
426
|
+
}>;
|
|
427
|
+
summary: { spanCount: number; totalPromptTokens: number };
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
expect(body.sessionId).toBe(sessionId);
|
|
431
|
+
expect(body.userId).toBe(userId);
|
|
432
|
+
expect(body.summary.spanCount).toBe(3);
|
|
433
|
+
expect(body.summary.totalPromptTokens).toBe(10 + 11 + 12);
|
|
434
|
+
// Each turn's input must be rehydratable for the chat view.
|
|
435
|
+
for (const span of body.spans) {
|
|
436
|
+
expect(span.inputJson).toMatch(/message \d/);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("7. Summary endpoint reports by-kind breakdown (Phoenix-style)", async () => {
|
|
441
|
+
const q = await fetch(`${BASE}/internal/ai/spans?hours=1`);
|
|
442
|
+
const body = (await q.json()) as AISpansResponse;
|
|
443
|
+
// After the tests above we should have at least LLM, TOOL, RETRIEVER
|
|
444
|
+
// represented in the window.
|
|
445
|
+
expect(body.summary.byKind.LLM).toBeGreaterThanOrEqual(1);
|
|
446
|
+
expect(body.summary.byKind.RETRIEVER).toBeGreaterThanOrEqual(1);
|
|
447
|
+
expect(body.summary.byKind.TOOL).toBeGreaterThanOrEqual(1);
|
|
448
|
+
});
|
|
449
|
+
});
|