@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,47 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { CollectorRuntime } from "../framework/collector";
|
|
4
|
+
import type { CollectorEnv } from "../framework/env";
|
|
5
|
+
import { tailRoutesPlugin } from "./tail-routes";
|
|
6
|
+
|
|
7
|
+
describe("tailRoutesPlugin", () => {
|
|
8
|
+
it("forwards the authenticated project id instead of trusting query params", async () => {
|
|
9
|
+
let forwardedUrl: string | null = null;
|
|
10
|
+
const app = new Hono<{
|
|
11
|
+
Bindings: CollectorEnv;
|
|
12
|
+
Variables: { projectId: string };
|
|
13
|
+
}>();
|
|
14
|
+
app.use("/internal/*", async (c, next) => {
|
|
15
|
+
c.set("projectId", "authorized-project");
|
|
16
|
+
await next();
|
|
17
|
+
});
|
|
18
|
+
tailRoutesPlugin.register(
|
|
19
|
+
app as unknown as Hono<{ Bindings: CollectorEnv }>,
|
|
20
|
+
new CollectorRuntime(),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const env = {
|
|
24
|
+
TAIL_HUB: {
|
|
25
|
+
idFromName: (name: string) => name,
|
|
26
|
+
get: () => ({
|
|
27
|
+
fetch: async (url: string) => {
|
|
28
|
+
forwardedUrl = url;
|
|
29
|
+
return new Response("ok", { status: 200 });
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
},
|
|
33
|
+
} as unknown as CollectorEnv;
|
|
34
|
+
|
|
35
|
+
const res = await app.request(
|
|
36
|
+
"/internal/telemetry/tail?projectId=attacker-project&kinds=span",
|
|
37
|
+
{ method: "GET" },
|
|
38
|
+
env,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(res.status).toBe(200);
|
|
42
|
+
expect(forwardedUrl).not.toBeNull();
|
|
43
|
+
const url = new URL(forwardedUrl ?? "");
|
|
44
|
+
expect(url.searchParams.get("projectId")).toBe("authorized-project");
|
|
45
|
+
expect(url.searchParams.get("kinds")).toBe("span");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { CollectorPlugin } from "../framework/collector";
|
|
2
|
+
import { getProjectId } from "./_context";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Live tail SSE endpoint. The dashboard opens an EventSource here; the
|
|
6
|
+
* collector proxies to the TailHub Durable Object which holds the open
|
|
7
|
+
* stream and fans out broadcasts from ingest.
|
|
8
|
+
*/
|
|
9
|
+
export const tailRoutesPlugin: CollectorPlugin = {
|
|
10
|
+
name: "tail-routes",
|
|
11
|
+
register(app) {
|
|
12
|
+
app.get("/internal/telemetry/tail", (c) => {
|
|
13
|
+
const hub = c.env.TAIL_HUB;
|
|
14
|
+
if (!hub) {
|
|
15
|
+
return c.json(
|
|
16
|
+
{ error: "Live tail not configured (TAIL_HUB binding missing)" },
|
|
17
|
+
503,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
// Project scope must come from auth middleware, never from the client
|
|
21
|
+
// URL. The dashboard live-tail client uses a fetch stream so it can
|
|
22
|
+
// send the same X-Project-Id header as the rest of the API.
|
|
23
|
+
const projectId = getProjectId(c);
|
|
24
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(projectId)) {
|
|
25
|
+
return c.json({ error: "invalid projectId" }, 400);
|
|
26
|
+
}
|
|
27
|
+
const kinds = c.req.query("kinds") || "span,log";
|
|
28
|
+
|
|
29
|
+
const id = hub.idFromName("singleton");
|
|
30
|
+
const stub = hub.get(id);
|
|
31
|
+
|
|
32
|
+
const subscribeUrl = new URL("https://hub/subscribe");
|
|
33
|
+
subscribeUrl.searchParams.set("projectId", projectId);
|
|
34
|
+
subscribeUrl.searchParams.set("kinds", kinds);
|
|
35
|
+
|
|
36
|
+
return stub.fetch(subscribeUrl.toString(), {
|
|
37
|
+
method: "GET",
|
|
38
|
+
signal: c.req.raw.signal,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import type { CollectorPlugin } from "../framework/collector";
|
|
2
|
+
import { IdentityIndex } from "../lib/identity-index";
|
|
3
|
+
import { sqlDbFor } from "../lib/sql-db";
|
|
4
|
+
import { getProjectId } from "./_context";
|
|
5
|
+
|
|
6
|
+
export interface TimelineEvent {
|
|
7
|
+
t: string;
|
|
8
|
+
kind: "span" | "log" | "usage";
|
|
9
|
+
id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
subtitle?: string;
|
|
12
|
+
severity?: "info" | "warn" | "error";
|
|
13
|
+
durationMs?: number;
|
|
14
|
+
/**
|
|
15
|
+
* RFC 0004 — click-scoped correlation key. Present on every event that
|
|
16
|
+
* was emitted while an interaction was active. The {@link TimelineResponse.groups}
|
|
17
|
+
* field groups events by this id so the dashboard can render
|
|
18
|
+
* "click → traces it caused" without a second round-trip.
|
|
19
|
+
*/
|
|
20
|
+
interactionId?: string;
|
|
21
|
+
payload: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TimelineReplayMetadata {
|
|
25
|
+
firstChunkAt: string;
|
|
26
|
+
lastChunkAt: string;
|
|
27
|
+
chunkCount: number;
|
|
28
|
+
eventsCount: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* RFC 0004 — a click and the traces it caused, bundled. The dashboard's
|
|
33
|
+
* session timeline collapses an interaction's events under its
|
|
34
|
+
* originating click; this is the precomputed shape the dashboard reads.
|
|
35
|
+
*/
|
|
36
|
+
export interface TimelineGroup {
|
|
37
|
+
interactionId: string;
|
|
38
|
+
/** The originating user-originated event (usage event, kind="interaction" or "page_view"). */
|
|
39
|
+
clickEvent: TimelineEvent | null;
|
|
40
|
+
/** Root spans (parent_span_id IS NULL) carrying this interaction_id. */
|
|
41
|
+
causedTraces: Array<{
|
|
42
|
+
traceId: string;
|
|
43
|
+
rootSpanId: string;
|
|
44
|
+
rootSpanName: string;
|
|
45
|
+
serviceName: string | null;
|
|
46
|
+
durationMs: number;
|
|
47
|
+
status: "ok" | "error";
|
|
48
|
+
}>;
|
|
49
|
+
/** Logs + non-click usage events sharing this interaction_id. */
|
|
50
|
+
relatedEvents: TimelineEvent[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface TimelineResponse {
|
|
54
|
+
sessionId: string;
|
|
55
|
+
firstSeen: string | null;
|
|
56
|
+
lastSeen: string | null;
|
|
57
|
+
counts: {
|
|
58
|
+
spans: number;
|
|
59
|
+
logs: number;
|
|
60
|
+
usage: number;
|
|
61
|
+
};
|
|
62
|
+
replay: TimelineReplayMetadata | null;
|
|
63
|
+
events: TimelineEvent[];
|
|
64
|
+
/**
|
|
65
|
+
* RFC 0004 — derived view keyed by interaction_id. Empty object when
|
|
66
|
+
* no events in the session carry one (sessions ingested before
|
|
67
|
+
* @obsunified/analytics-sdk's autoCorrelate landed). Events without an
|
|
68
|
+
* interaction_id appear in `events` but never under `groups`.
|
|
69
|
+
*/
|
|
70
|
+
groups: Record<string, TimelineGroup>;
|
|
71
|
+
timestamp: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const severityFromStatus = (code: number): "info" | "warn" | "error" =>
|
|
75
|
+
code === 2 ? "error" : "info";
|
|
76
|
+
|
|
77
|
+
const severityFromLog = (
|
|
78
|
+
severity: string | null,
|
|
79
|
+
): "info" | "warn" | "error" => {
|
|
80
|
+
if (severity === "ERROR" || severity === "FATAL") return "error";
|
|
81
|
+
if (severity === "WARN") return "warn";
|
|
82
|
+
return "info";
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const severityFromUsage = (
|
|
86
|
+
type: string,
|
|
87
|
+
severity: string | null,
|
|
88
|
+
): "info" | "warn" | "error" => {
|
|
89
|
+
if (type === "frontend_error" || severity === "error") return "error";
|
|
90
|
+
if (severity === "warning") return "warn";
|
|
91
|
+
return "info";
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const timelineRoutesPlugin: CollectorPlugin = {
|
|
95
|
+
name: "timeline-routes",
|
|
96
|
+
register(app) {
|
|
97
|
+
app.get("/internal/timeline/:sessionId", async (c) => {
|
|
98
|
+
const projectId = getProjectId(c);
|
|
99
|
+
const sessionId = c.req.param("sessionId");
|
|
100
|
+
if (!sessionId) {
|
|
101
|
+
return c.json({ error: "sessionId required" }, 400);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// RFC 0008 — go through the IdentityIndex helper rather than
|
|
105
|
+
// duplicating per-signal SQL. Same indexed lookup, single
|
|
106
|
+
// ownership of the cross-signal join shape.
|
|
107
|
+
const index = new IdentityIndex(sqlDbFor(c.env));
|
|
108
|
+
const manifest = await index.bySession(projectId, sessionId);
|
|
109
|
+
|
|
110
|
+
const events: TimelineEvent[] = [];
|
|
111
|
+
// Stash root-span shapes for grouping. Indexed by trace_id since a
|
|
112
|
+
// single click can fan out into multiple service-level traces (rare
|
|
113
|
+
// for a normal browser → single backend, but defensible).
|
|
114
|
+
const rootSpansByInteraction = new Map<
|
|
115
|
+
string,
|
|
116
|
+
TimelineGroup["causedTraces"]
|
|
117
|
+
>();
|
|
118
|
+
|
|
119
|
+
for (const s of manifest.spans) {
|
|
120
|
+
const interactionId = s.interactionId ?? undefined;
|
|
121
|
+
events.push({
|
|
122
|
+
t: s.startTime,
|
|
123
|
+
kind: "span",
|
|
124
|
+
id: `${s.traceId}:${s.spanId}`,
|
|
125
|
+
title: `${s.serviceName ?? "unknown"} · ${s.spanName}`,
|
|
126
|
+
subtitle: s.statusMessage ?? undefined,
|
|
127
|
+
severity: severityFromStatus(s.statusCode),
|
|
128
|
+
durationMs: s.durationMs,
|
|
129
|
+
interactionId,
|
|
130
|
+
payload: {
|
|
131
|
+
traceId: s.traceId,
|
|
132
|
+
spanId: s.spanId,
|
|
133
|
+
parentSpanId: s.parentSpanId,
|
|
134
|
+
statusCode: s.statusCode,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
// Only root spans count as "the trace this click caused" — child
|
|
138
|
+
// spans are part of the same trace tree, so collecting them
|
|
139
|
+
// would double-count.
|
|
140
|
+
if (interactionId && s.parentSpanId === null) {
|
|
141
|
+
const list = rootSpansByInteraction.get(interactionId) ?? [];
|
|
142
|
+
list.push({
|
|
143
|
+
traceId: s.traceId,
|
|
144
|
+
rootSpanId: s.spanId,
|
|
145
|
+
rootSpanName: s.spanName,
|
|
146
|
+
serviceName: s.serviceName,
|
|
147
|
+
durationMs: s.durationMs,
|
|
148
|
+
status: s.statusCode === 2 ? "error" : "ok",
|
|
149
|
+
});
|
|
150
|
+
rootSpansByInteraction.set(interactionId, list);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const l of manifest.logs) {
|
|
155
|
+
events.push({
|
|
156
|
+
t: l.occurredAt,
|
|
157
|
+
kind: "log",
|
|
158
|
+
id: l.logId,
|
|
159
|
+
title: l.message.slice(0, 160),
|
|
160
|
+
subtitle:
|
|
161
|
+
`${l.serviceName ?? ""}${l.loggerName ? ` · ${l.loggerName}` : ""}`.trim(),
|
|
162
|
+
severity: severityFromLog(l.severity),
|
|
163
|
+
interactionId: l.interactionId ?? undefined,
|
|
164
|
+
payload: {
|
|
165
|
+
severity: l.severity,
|
|
166
|
+
traceId: l.traceId,
|
|
167
|
+
spanId: l.spanId,
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const u of manifest.usageEvents) {
|
|
173
|
+
events.push({
|
|
174
|
+
t: u.occurredAt,
|
|
175
|
+
kind: "usage",
|
|
176
|
+
id: u.eventId,
|
|
177
|
+
title: `${u.eventType}${u.eventName && u.eventName !== u.eventType ? ` · ${u.eventName}` : ""}`,
|
|
178
|
+
subtitle: u.pagePath ?? undefined,
|
|
179
|
+
severity: severityFromUsage(u.eventType, u.severity),
|
|
180
|
+
interactionId: u.interactionId ?? undefined,
|
|
181
|
+
payload: {
|
|
182
|
+
eventType: u.eventType,
|
|
183
|
+
eventName: u.eventName,
|
|
184
|
+
pagePath: u.pagePath,
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
events.sort((a, b) => a.t.localeCompare(b.t));
|
|
190
|
+
|
|
191
|
+
// RFC 0004 — derive groups keyed by interaction_id.
|
|
192
|
+
const groups: Record<string, TimelineGroup> = {};
|
|
193
|
+
for (const event of events) {
|
|
194
|
+
if (!event.interactionId) continue;
|
|
195
|
+
let group = groups[event.interactionId];
|
|
196
|
+
if (!group) {
|
|
197
|
+
group = {
|
|
198
|
+
interactionId: event.interactionId,
|
|
199
|
+
clickEvent: null,
|
|
200
|
+
causedTraces: rootSpansByInteraction.get(event.interactionId) ?? [],
|
|
201
|
+
relatedEvents: [],
|
|
202
|
+
};
|
|
203
|
+
groups[event.interactionId] = group;
|
|
204
|
+
}
|
|
205
|
+
// Treat the first usage event with this id as the click that
|
|
206
|
+
// originated it. Usage events are minted at addEventListener
|
|
207
|
+
// fire time so they're timestamp-prior to any fetch the
|
|
208
|
+
// handler kicks off.
|
|
209
|
+
if (event.kind === "usage" && group.clickEvent === null) {
|
|
210
|
+
group.clickEvent = event;
|
|
211
|
+
} else {
|
|
212
|
+
group.relatedEvents.push(event);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const firstSeen = events[0]?.t ?? null;
|
|
217
|
+
const lastSeen = events[events.length - 1]?.t ?? null;
|
|
218
|
+
|
|
219
|
+
const response: TimelineResponse = {
|
|
220
|
+
sessionId,
|
|
221
|
+
firstSeen,
|
|
222
|
+
lastSeen,
|
|
223
|
+
counts: {
|
|
224
|
+
spans: manifest.spans.length,
|
|
225
|
+
logs: manifest.logs.length,
|
|
226
|
+
usage: manifest.usageEvents.length,
|
|
227
|
+
},
|
|
228
|
+
replay: manifest.replay
|
|
229
|
+
? {
|
|
230
|
+
firstChunkAt: manifest.replay.firstChunkAt,
|
|
231
|
+
lastChunkAt: manifest.replay.lastChunkAt,
|
|
232
|
+
chunkCount: manifest.replay.chunkCount,
|
|
233
|
+
eventsCount: manifest.replay.eventsCount,
|
|
234
|
+
}
|
|
235
|
+
: null,
|
|
236
|
+
events,
|
|
237
|
+
groups,
|
|
238
|
+
timestamp: new Date().toISOString(),
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
return c.json(response);
|
|
242
|
+
});
|
|
243
|
+
},
|
|
244
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CollectorPlugin,
|
|
3
|
+
UsageEventProcessorPlugin,
|
|
4
|
+
} from "../framework/collector";
|
|
5
|
+
import { parseUserAgent } from "../lib/ua-parser";
|
|
6
|
+
|
|
7
|
+
const processor: UsageEventProcessorPlugin = {
|
|
8
|
+
name: "ua-enrichment",
|
|
9
|
+
process(events) {
|
|
10
|
+
return events.map((event) => {
|
|
11
|
+
const parsed = parseUserAgent(event.userAgent);
|
|
12
|
+
return {
|
|
13
|
+
...event,
|
|
14
|
+
browser: parsed.browser,
|
|
15
|
+
os: parsed.os,
|
|
16
|
+
deviceType: parsed.deviceType,
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const uaEnrichmentPlugin: CollectorPlugin = {
|
|
23
|
+
name: "ua-enrichment",
|
|
24
|
+
register(_app, runtime) {
|
|
25
|
+
runtime.addUsageEventProcessor(processor);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { JsonValue, UsageEventRecord } from "@obsunified/types";
|
|
2
|
+
import type { CollectorPlugin } from "../framework/collector";
|
|
3
|
+
import { parseJsonRecord } from "../lib/json";
|
|
4
|
+
|
|
5
|
+
const normalizePathSegment = (segment: string): string => {
|
|
6
|
+
if (!segment) return segment;
|
|
7
|
+
if (/^\d+$/.test(segment)) return ":id";
|
|
8
|
+
if (/^[0-9a-f]{16,}$/i.test(segment)) return ":id";
|
|
9
|
+
return segment;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const normalizePath = (path: string | null): string | null => {
|
|
13
|
+
if (!path) return null;
|
|
14
|
+
const [pathname] = path.split("?");
|
|
15
|
+
const segments = pathname
|
|
16
|
+
.split("/")
|
|
17
|
+
.map(normalizePathSegment)
|
|
18
|
+
.filter((segment, index) => !(index === 0 && segment === ""));
|
|
19
|
+
return `/${segments.join("/")}`.replace(/\/{2,}/g, "/");
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const usageNormalizationPlugin: CollectorPlugin = {
|
|
23
|
+
name: "usage-normalization",
|
|
24
|
+
register(_app, runtime) {
|
|
25
|
+
runtime.addUsageEventProcessor({
|
|
26
|
+
name: "usage-normalization",
|
|
27
|
+
process(events) {
|
|
28
|
+
return events.map((event): UsageEventRecord => {
|
|
29
|
+
const context = parseJsonRecord(event.contextJson);
|
|
30
|
+
const normalizedPath = normalizePath(event.pagePath);
|
|
31
|
+
const nextContext: Record<string, JsonValue> = {
|
|
32
|
+
...context,
|
|
33
|
+
"collector.is_admin_path": Boolean(
|
|
34
|
+
normalizedPath?.startsWith("/admin"),
|
|
35
|
+
),
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
...event,
|
|
39
|
+
pagePath: normalizedPath,
|
|
40
|
+
contextJson: JSON.stringify(nextContext),
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { JsonValue, UsageEventRecord } from "@obsunified/types";
|
|
2
|
+
import type { CollectorPlugin } from "../framework/collector";
|
|
3
|
+
import { parseJsonValue } from "../lib/json";
|
|
4
|
+
|
|
5
|
+
const REDACTED_VALUE = "[REDACTED]";
|
|
6
|
+
const DEFAULT_REDACT_FIELDS = [
|
|
7
|
+
"email",
|
|
8
|
+
"token",
|
|
9
|
+
"password",
|
|
10
|
+
"authorization",
|
|
11
|
+
"cookie",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const shouldRedactKey = (key: string): boolean => {
|
|
15
|
+
const normalized = key.toLowerCase();
|
|
16
|
+
return DEFAULT_REDACT_FIELDS.some(
|
|
17
|
+
(field) => normalized === field || normalized.endsWith(field),
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const redactJson = (value: JsonValue): JsonValue => {
|
|
22
|
+
if (Array.isArray(value)) return value.map(redactJson);
|
|
23
|
+
if (value && typeof value === "object") {
|
|
24
|
+
return Object.fromEntries(
|
|
25
|
+
Object.entries(value).map(([key, nestedValue]) =>
|
|
26
|
+
shouldRedactKey(key)
|
|
27
|
+
? [key, REDACTED_VALUE]
|
|
28
|
+
: [key, redactJson(nestedValue)],
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const usagePrivacyPlugin: CollectorPlugin = {
|
|
36
|
+
name: "usage-privacy",
|
|
37
|
+
register(_app, runtime) {
|
|
38
|
+
runtime.addUsageEventProcessor({
|
|
39
|
+
name: "usage-privacy",
|
|
40
|
+
process(events) {
|
|
41
|
+
return events.map(
|
|
42
|
+
(event): UsageEventRecord => ({
|
|
43
|
+
...event,
|
|
44
|
+
contextJson: JSON.stringify(
|
|
45
|
+
redactJson(parseJsonValue(event.contextJson)),
|
|
46
|
+
),
|
|
47
|
+
propertiesJson: JSON.stringify(
|
|
48
|
+
redactJson(parseJsonValue(event.propertiesJson)),
|
|
49
|
+
),
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_WINDOW_HOURS,
|
|
3
|
+
getConfiguredRetentionHours,
|
|
4
|
+
} from "@obsunified/types/constants";
|
|
5
|
+
import type { CollectorPlugin } from "../framework/collector";
|
|
6
|
+
import { getProjectId } from "./_context";
|
|
7
|
+
|
|
8
|
+
export const usageQueryRoutesPlugin: CollectorPlugin = {
|
|
9
|
+
name: "usage-query-routes",
|
|
10
|
+
register(app, runtime) {
|
|
11
|
+
app.get("/internal/usage/overview", async (c) => {
|
|
12
|
+
const projectId = getProjectId(c);
|
|
13
|
+
const maxHours = getConfiguredRetentionHours(c.env.RETENTION_HOURS);
|
|
14
|
+
const hours = Math.min(
|
|
15
|
+
maxHours,
|
|
16
|
+
Math.max(
|
|
17
|
+
1,
|
|
18
|
+
Number.parseInt(
|
|
19
|
+
c.req.query("hours") || String(DEFAULT_WINDOW_HOURS),
|
|
20
|
+
10,
|
|
21
|
+
) || DEFAULT_WINDOW_HOURS,
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const store = runtime.createUsageStore(c.env);
|
|
26
|
+
const overview = await store.getOverview({
|
|
27
|
+
projectId,
|
|
28
|
+
hours,
|
|
29
|
+
path: c.req.query("path") || undefined,
|
|
30
|
+
includeAdmin: c.req.query("includeAdmin") === "true",
|
|
31
|
+
});
|
|
32
|
+
return c.json({
|
|
33
|
+
...overview,
|
|
34
|
+
plugins: runtime.getRegisteredPluginNames(),
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
app.get("/internal/usage/sessions", async (c) => {
|
|
39
|
+
const projectId = getProjectId(c);
|
|
40
|
+
const maxHours = getConfiguredRetentionHours(c.env.RETENTION_HOURS);
|
|
41
|
+
const hours = Math.min(
|
|
42
|
+
maxHours,
|
|
43
|
+
Math.max(
|
|
44
|
+
1,
|
|
45
|
+
Number.parseInt(
|
|
46
|
+
c.req.query("hours") || String(DEFAULT_WINDOW_HOURS),
|
|
47
|
+
10,
|
|
48
|
+
) || DEFAULT_WINDOW_HOURS,
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
const filterParam = c.req.query("filter");
|
|
52
|
+
const filter: "all" | "ended_in_error" | "dropoff" | "slow" =
|
|
53
|
+
filterParam === "ended_in_error" ||
|
|
54
|
+
filterParam === "dropoff" ||
|
|
55
|
+
filterParam === "slow"
|
|
56
|
+
? filterParam
|
|
57
|
+
: "all";
|
|
58
|
+
const slowMs = Math.max(
|
|
59
|
+
100,
|
|
60
|
+
Number.parseInt(c.req.query("slowMs") || "3000", 10) || 3000,
|
|
61
|
+
);
|
|
62
|
+
const limit = Math.max(
|
|
63
|
+
1,
|
|
64
|
+
Math.min(
|
|
65
|
+
500,
|
|
66
|
+
Number.parseInt(c.req.query("limit") || "100", 10) || 100,
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const store = runtime.createUsageStore(c.env);
|
|
71
|
+
const result = await store.listSessions({
|
|
72
|
+
projectId,
|
|
73
|
+
hours,
|
|
74
|
+
filter,
|
|
75
|
+
slowMs,
|
|
76
|
+
limit,
|
|
77
|
+
});
|
|
78
|
+
return c.json({
|
|
79
|
+
...result,
|
|
80
|
+
filter,
|
|
81
|
+
hours,
|
|
82
|
+
slowMs,
|
|
83
|
+
timestamp: new Date().toISOString(),
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
app.get("/internal/usage/sessions/:sessionId", async (c) => {
|
|
88
|
+
const projectId = getProjectId(c);
|
|
89
|
+
const store = runtime.createUsageStore(c.env);
|
|
90
|
+
const detail = await store.getSessionDetail(
|
|
91
|
+
c.req.param("sessionId"),
|
|
92
|
+
projectId,
|
|
93
|
+
);
|
|
94
|
+
if (!detail)
|
|
95
|
+
return c.json(
|
|
96
|
+
{ error: "Not Found", message: "Session not found" },
|
|
97
|
+
404,
|
|
98
|
+
);
|
|
99
|
+
return c.json({ ...detail, plugins: runtime.getRegisteredPluginNames() });
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** Union: A's JSON error handling, event limit, env-based retention. P/D's dynamic CORS. */
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
UsageEventInput,
|
|
5
|
+
UsageEventPayload,
|
|
6
|
+
UsageEventRecord,
|
|
7
|
+
} from "@obsunified/types";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_WINDOW_HOURS,
|
|
10
|
+
getConfiguredRetentionHours,
|
|
11
|
+
} from "@obsunified/types/constants";
|
|
12
|
+
import type { CollectorPlugin } from "../framework/collector";
|
|
13
|
+
import { retentionExpiry } from "../lib/otlp";
|
|
14
|
+
import { getProjectId } from "./_context";
|
|
15
|
+
|
|
16
|
+
const sanitizeIsoTimestamp = (value?: string): string => {
|
|
17
|
+
if (!value) return new Date().toISOString();
|
|
18
|
+
const parsed = new Date(value);
|
|
19
|
+
return Number.isNaN(parsed.getTime())
|
|
20
|
+
? new Date().toISOString()
|
|
21
|
+
: parsed.toISOString();
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const toUsageRecord = (
|
|
25
|
+
input: UsageEventInput,
|
|
26
|
+
userAgent: string | null,
|
|
27
|
+
country: string | null,
|
|
28
|
+
receivedAt: Date,
|
|
29
|
+
projectId: string,
|
|
30
|
+
retentionHours?: number,
|
|
31
|
+
): UsageEventRecord => ({
|
|
32
|
+
projectId,
|
|
33
|
+
eventId: crypto.randomUUID(),
|
|
34
|
+
sessionId: input.sessionId,
|
|
35
|
+
visitorId: input.visitorId,
|
|
36
|
+
eventType: input.type,
|
|
37
|
+
eventName: input.name,
|
|
38
|
+
pagePath: input.path || null,
|
|
39
|
+
pageTitle: input.title || null,
|
|
40
|
+
referrer: input.referrer || null,
|
|
41
|
+
severity: input.severity || "info",
|
|
42
|
+
source: "web",
|
|
43
|
+
contextJson: JSON.stringify(input.context || {}),
|
|
44
|
+
propertiesJson: JSON.stringify(input.properties || {}),
|
|
45
|
+
userAgent,
|
|
46
|
+
occurredAt: sanitizeIsoTimestamp(input.occurredAt),
|
|
47
|
+
receivedAt: receivedAt.toISOString(),
|
|
48
|
+
expiresAt: retentionExpiry(receivedAt, retentionHours),
|
|
49
|
+
country,
|
|
50
|
+
browser: null,
|
|
51
|
+
os: null,
|
|
52
|
+
deviceType: null,
|
|
53
|
+
isBot: false,
|
|
54
|
+
utmSource: null,
|
|
55
|
+
utmMedium: null,
|
|
56
|
+
utmCampaign: null,
|
|
57
|
+
// RFC 0004 — opaque correlation id from the analytics SDK; null when
|
|
58
|
+
// the event was emitted outside any user interaction.
|
|
59
|
+
interactionId: input.interactionId || null,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export const usageReceiverPlugin: CollectorPlugin = {
|
|
63
|
+
name: "usage-receiver",
|
|
64
|
+
register(app, runtime) {
|
|
65
|
+
// CORS is handled by the framework-level /v1/* middleware.
|
|
66
|
+
// No per-route CORS needed here.
|
|
67
|
+
|
|
68
|
+
app.post("/v1/usage", async (c) => {
|
|
69
|
+
const projectId = getProjectId(c);
|
|
70
|
+
let payload: UsageEventPayload;
|
|
71
|
+
try {
|
|
72
|
+
payload = await c.req.json<UsageEventPayload>();
|
|
73
|
+
} catch {
|
|
74
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
75
|
+
}
|
|
76
|
+
const rawEvents = payload.events || [];
|
|
77
|
+
if (rawEvents.length > 200) {
|
|
78
|
+
return c.json(
|
|
79
|
+
{ error: `Too many events: ${rawEvents.length} (max 200)` },
|
|
80
|
+
413,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const validInputs = rawEvents.filter((event): event is UsageEventInput =>
|
|
84
|
+
Boolean(
|
|
85
|
+
event?.type && event?.name && event?.sessionId && event?.visitorId,
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const receivedAt = new Date();
|
|
90
|
+
const retentionHours = getConfiguredRetentionHours(c.env.RETENTION_HOURS);
|
|
91
|
+
const country = c.req.header("X-Client-Country") || null;
|
|
92
|
+
const records = validInputs.map((event) =>
|
|
93
|
+
toUsageRecord(
|
|
94
|
+
event,
|
|
95
|
+
c.req.header("User-Agent") || null,
|
|
96
|
+
country,
|
|
97
|
+
receivedAt,
|
|
98
|
+
projectId,
|
|
99
|
+
retentionHours,
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const processed = await runtime.runUsageEventProcessors(
|
|
104
|
+
records,
|
|
105
|
+
runtime.createRouteContext(c.env, c),
|
|
106
|
+
);
|
|
107
|
+
const store = runtime.createUsageStore(c.env);
|
|
108
|
+
const result = await runtime.withChildSpan(
|
|
109
|
+
"usage.ingest",
|
|
110
|
+
async (span) => {
|
|
111
|
+
const r = await store.ingest(processed);
|
|
112
|
+
span.setAttribute("usage.events_received", rawEvents.length);
|
|
113
|
+
span.setAttribute("usage.events_valid", validInputs.length);
|
|
114
|
+
span.setAttribute("usage.events_inserted", r.inserted);
|
|
115
|
+
span.setAttribute("usage.session_count", r.sessionCount);
|
|
116
|
+
span.setAttribute("project.id", projectId);
|
|
117
|
+
return r;
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
return c.json({
|
|
122
|
+
success: true,
|
|
123
|
+
inserted: result.inserted,
|
|
124
|
+
sessionCount: result.sessionCount,
|
|
125
|
+
acceptedWindowHours: DEFAULT_WINDOW_HOURS,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
};
|