@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,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0004 Phase 1.8 — propagation aggregator tests.
|
|
3
|
+
*
|
|
4
|
+
* Exercises the SQL the aggregator issues against a structural fake
|
|
5
|
+
* database. Validates:
|
|
6
|
+
* - the count queries pin to project_id + the hour cutoff
|
|
7
|
+
* - 8 metric points emerge (4 signals × 2 propagated values)
|
|
8
|
+
* - point identities are stable across runs (so the cron upserts series)
|
|
9
|
+
* - zero rows still produce points with value=0 (visible-empty-state)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it, vi } from "vitest";
|
|
13
|
+
import type { Logger } from "../framework/logger";
|
|
14
|
+
import { aggregatePropagationForProject } from "./propagation-metric";
|
|
15
|
+
import type { SqlDb } from "./sql-db";
|
|
16
|
+
|
|
17
|
+
interface FakeRow {
|
|
18
|
+
propagated: number;
|
|
19
|
+
missing: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class FakeDb {
|
|
23
|
+
calls: Array<{ sql: string; binds: unknown[] }> = [];
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly counts: Record<string, FakeRow>,
|
|
26
|
+
private readonly seriesExisting = new Map<string, string>(),
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
prepare(sql: string) {
|
|
30
|
+
const calls = this.calls;
|
|
31
|
+
const counts = this.counts;
|
|
32
|
+
const seriesExisting = this.seriesExisting;
|
|
33
|
+
let binds: unknown[] = [];
|
|
34
|
+
const stmt: {
|
|
35
|
+
bind: (...args: unknown[]) => typeof stmt;
|
|
36
|
+
first: <T>() => Promise<T | null>;
|
|
37
|
+
all: <T>() => Promise<{ results: T[] }>;
|
|
38
|
+
run: () => Promise<{ meta: { changes: number } }>;
|
|
39
|
+
} = {
|
|
40
|
+
bind: (...args: unknown[]) => {
|
|
41
|
+
binds = args;
|
|
42
|
+
return stmt;
|
|
43
|
+
},
|
|
44
|
+
first: async <T>() => {
|
|
45
|
+
calls.push({ sql, binds: [...binds] });
|
|
46
|
+
if (sql.includes("FROM telemetry_spans"))
|
|
47
|
+
return counts.telemetry_spans as T;
|
|
48
|
+
if (sql.includes("FROM logs")) return counts.logs as T;
|
|
49
|
+
if (sql.includes("FROM usage_events")) return counts.usage_events as T;
|
|
50
|
+
if (sql.includes("FROM ai_calls")) return counts.ai_calls as T;
|
|
51
|
+
return null;
|
|
52
|
+
},
|
|
53
|
+
all: async <T>() => {
|
|
54
|
+
calls.push({ sql, binds: [...binds] });
|
|
55
|
+
if (sql.includes("FROM metric_series")) {
|
|
56
|
+
// MetricsStore.resolveSeries SELECT — return any pre-seeded
|
|
57
|
+
// series so the aggregator reuses ids on repeat runs.
|
|
58
|
+
const identities = binds.slice(1) as string[];
|
|
59
|
+
const results = identities
|
|
60
|
+
.filter((id) => seriesExisting.has(id))
|
|
61
|
+
.map((id) => ({
|
|
62
|
+
id: seriesExisting.get(id),
|
|
63
|
+
identity: id,
|
|
64
|
+
}));
|
|
65
|
+
return { results: results as unknown as T[] };
|
|
66
|
+
}
|
|
67
|
+
return { results: [] };
|
|
68
|
+
},
|
|
69
|
+
run: async () => {
|
|
70
|
+
calls.push({ sql, binds: [...binds] });
|
|
71
|
+
return { meta: { changes: 1 } };
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
return stmt;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async batch(stmts: unknown[]) {
|
|
78
|
+
return stmts.map(() => ({ success: true, meta: { changes: 1 } }));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("aggregatePropagationForProject", () => {
|
|
83
|
+
it("queries 4 signal tables with project_id + hour cutoff", async () => {
|
|
84
|
+
const db = new FakeDb({
|
|
85
|
+
telemetry_spans: { propagated: 12, missing: 3 },
|
|
86
|
+
logs: { propagated: 8, missing: 0 },
|
|
87
|
+
usage_events: { propagated: 5, missing: 7 },
|
|
88
|
+
ai_calls: { propagated: 2, missing: 1 },
|
|
89
|
+
});
|
|
90
|
+
const now = new Date("2026-05-04T12:30:00.000Z");
|
|
91
|
+
await aggregatePropagationForProject(db as unknown as SqlDb, "p1", now);
|
|
92
|
+
|
|
93
|
+
const expectedCutoff = "2026-05-04T11:30:00.000Z";
|
|
94
|
+
|
|
95
|
+
const countCalls = db.calls.filter((c) =>
|
|
96
|
+
c.sql.includes("SUM(CASE WHEN interaction_id"),
|
|
97
|
+
);
|
|
98
|
+
expect(countCalls).toHaveLength(4);
|
|
99
|
+
for (const call of countCalls) {
|
|
100
|
+
expect(call.binds[0]).toBe("p1");
|
|
101
|
+
expect(call.binds[1]).toBe(expectedCutoff);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("writes 8 metric points (4 signals × 2 propagated values)", async () => {
|
|
106
|
+
const db = new FakeDb({
|
|
107
|
+
telemetry_spans: { propagated: 12, missing: 3 },
|
|
108
|
+
logs: { propagated: 8, missing: 0 },
|
|
109
|
+
usage_events: { propagated: 5, missing: 7 },
|
|
110
|
+
ai_calls: { propagated: 2, missing: 1 },
|
|
111
|
+
});
|
|
112
|
+
const result = await aggregatePropagationForProject(
|
|
113
|
+
db as unknown as SqlDb,
|
|
114
|
+
"p1",
|
|
115
|
+
new Date(),
|
|
116
|
+
);
|
|
117
|
+
expect(result.pointsWritten).toBe(8);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("includes zero-count points so the dashboard sees both buckets", async () => {
|
|
121
|
+
const db = new FakeDb({
|
|
122
|
+
telemetry_spans: { propagated: 0, missing: 0 },
|
|
123
|
+
logs: { propagated: 0, missing: 0 },
|
|
124
|
+
usage_events: { propagated: 0, missing: 0 },
|
|
125
|
+
ai_calls: { propagated: 0, missing: 0 },
|
|
126
|
+
});
|
|
127
|
+
const result = await aggregatePropagationForProject(
|
|
128
|
+
db as unknown as SqlDb,
|
|
129
|
+
"p1",
|
|
130
|
+
new Date(),
|
|
131
|
+
);
|
|
132
|
+
expect(result.pointsWritten).toBe(8);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("treats null SUM as 0 (empty signal table edge case)", async () => {
|
|
136
|
+
const db = new FakeDb({
|
|
137
|
+
telemetry_spans: {
|
|
138
|
+
propagated: null,
|
|
139
|
+
missing: null,
|
|
140
|
+
} as unknown as FakeRow,
|
|
141
|
+
logs: { propagated: null, missing: null } as unknown as FakeRow,
|
|
142
|
+
usage_events: { propagated: null, missing: null } as unknown as FakeRow,
|
|
143
|
+
ai_calls: { propagated: null, missing: null } as unknown as FakeRow,
|
|
144
|
+
});
|
|
145
|
+
const result = await aggregatePropagationForProject(
|
|
146
|
+
db as unknown as SqlDb,
|
|
147
|
+
"p1",
|
|
148
|
+
new Date(),
|
|
149
|
+
);
|
|
150
|
+
expect(result.pointsWritten).toBe(8);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("logs and continues when a single signal count fails", async () => {
|
|
154
|
+
// Make ONE signal throw on its count by using a db that throws for a
|
|
155
|
+
// specific table.
|
|
156
|
+
const baseDb = new FakeDb({
|
|
157
|
+
telemetry_spans: { propagated: 1, missing: 0 },
|
|
158
|
+
logs: { propagated: 1, missing: 0 },
|
|
159
|
+
usage_events: { propagated: 1, missing: 0 },
|
|
160
|
+
ai_calls: { propagated: 1, missing: 0 },
|
|
161
|
+
});
|
|
162
|
+
const originalPrepare = baseDb.prepare.bind(baseDb);
|
|
163
|
+
baseDb.prepare = (sql: string) => {
|
|
164
|
+
if (sql.includes("FROM logs")) {
|
|
165
|
+
return {
|
|
166
|
+
bind: () => ({
|
|
167
|
+
first: async () => {
|
|
168
|
+
throw new Error("boom");
|
|
169
|
+
},
|
|
170
|
+
all: async () => ({ results: [] }),
|
|
171
|
+
run: async () => ({ meta: { changes: 0 } }),
|
|
172
|
+
}),
|
|
173
|
+
} as unknown as ReturnType<typeof originalPrepare>;
|
|
174
|
+
}
|
|
175
|
+
return originalPrepare(sql);
|
|
176
|
+
};
|
|
177
|
+
const logger = {
|
|
178
|
+
error: vi.fn(),
|
|
179
|
+
info: vi.fn(),
|
|
180
|
+
warn: vi.fn(),
|
|
181
|
+
debug: vi.fn(),
|
|
182
|
+
};
|
|
183
|
+
const result = await aggregatePropagationForProject(
|
|
184
|
+
baseDb as unknown as SqlDb,
|
|
185
|
+
"p1",
|
|
186
|
+
new Date(),
|
|
187
|
+
logger as Logger,
|
|
188
|
+
);
|
|
189
|
+
// Three signals succeed (×2 points each) — logs throws, so 6 points.
|
|
190
|
+
expect(result.pointsWritten).toBe(6);
|
|
191
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
192
|
+
"[propagation-metric] count failed",
|
|
193
|
+
expect.objectContaining({ signal: "log", projectId: "p1" }),
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0004 Phase 1.8 — interaction_id propagation metric.
|
|
3
|
+
*
|
|
4
|
+
* Hourly aggregator: counts how many records in each signal table for
|
|
5
|
+
* each project carry an interaction_id versus null over the last hour.
|
|
6
|
+
* Writes 8 metric points per project per hour to metric_point as the
|
|
7
|
+
* standard OTel counter `obs.interaction.propagation` with attributes
|
|
8
|
+
* `(signal, propagated)`. The Health dashboard's tile (Phase 1.10
|
|
9
|
+
* follow-up or RFC 0006 Connected rail consumer) reads these series and
|
|
10
|
+
* surfaces the ratio per signal.
|
|
11
|
+
*
|
|
12
|
+
* Why aggregate via cron rather than emit per-request:
|
|
13
|
+
* - The signal tables already have the data; a hourly count is two
|
|
14
|
+
* indexed COUNT(*) queries per signal — sub-ms.
|
|
15
|
+
* - Per-request emission adds a ~2x write amplification across every
|
|
16
|
+
* ingest path. The metric is operational, not analytical, and an
|
|
17
|
+
* hourly cadence is enough to surface drift.
|
|
18
|
+
*
|
|
19
|
+
* Series identity is stable so the cron upserts the same series rows
|
|
20
|
+
* each run rather than creating new ones.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { Logger } from "../framework/logger";
|
|
24
|
+
import type { DecodedMetricPoint } from "../otlp/decode";
|
|
25
|
+
import { MetricsStore } from "./metrics-store";
|
|
26
|
+
import type { SqlDb } from "./sql-db";
|
|
27
|
+
|
|
28
|
+
const PROPAGATION_METRIC_NAME = "obs.interaction.propagation";
|
|
29
|
+
|
|
30
|
+
type Signal = "span" | "log" | "usage" | "ai_call";
|
|
31
|
+
|
|
32
|
+
interface SignalQuery {
|
|
33
|
+
signal: Signal;
|
|
34
|
+
table: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SIGNALS: SignalQuery[] = [
|
|
38
|
+
{ signal: "span", table: "telemetry_spans" },
|
|
39
|
+
{ signal: "log", table: "logs" },
|
|
40
|
+
{ signal: "usage", table: "usage_events" },
|
|
41
|
+
{ signal: "ai_call", table: "ai_calls" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Stable identity string for the (name, attributes) tuple. Mirrors the
|
|
46
|
+
* format MetricsStore writes for OTLP-ingested points so a query joining
|
|
47
|
+
* on metric_series.identity gets a consistent result regardless of
|
|
48
|
+
* source.
|
|
49
|
+
*/
|
|
50
|
+
const propagationIdentity = (signal: Signal, propagated: boolean): string =>
|
|
51
|
+
`${PROPAGATION_METRIC_NAME}|signal=${signal}|propagated=${propagated}`;
|
|
52
|
+
|
|
53
|
+
const buildPoint = (
|
|
54
|
+
signal: Signal,
|
|
55
|
+
propagated: boolean,
|
|
56
|
+
count: number,
|
|
57
|
+
tsNs: string,
|
|
58
|
+
): DecodedMetricPoint => ({
|
|
59
|
+
name: PROPAGATION_METRIC_NAME,
|
|
60
|
+
description:
|
|
61
|
+
"Count of signal records ingested in the last hour, partitioned by whether they carry interaction_id (RFC 0004).",
|
|
62
|
+
unit: "{records}",
|
|
63
|
+
type: "sum",
|
|
64
|
+
isMonotonic: false, // delta over a one-hour window, not cumulative
|
|
65
|
+
temporality: 1, // DELTA
|
|
66
|
+
scopeName: "obs.collector",
|
|
67
|
+
scopeVersion: "1",
|
|
68
|
+
serviceName: "obs-collector",
|
|
69
|
+
resourceAttrsJson: null,
|
|
70
|
+
attributesJson: JSON.stringify({ signal, propagated }),
|
|
71
|
+
identity: propagationIdentity(signal, propagated),
|
|
72
|
+
tsNs,
|
|
73
|
+
startTsNs: null,
|
|
74
|
+
value: count,
|
|
75
|
+
count: null,
|
|
76
|
+
sum: null,
|
|
77
|
+
min: null,
|
|
78
|
+
max: null,
|
|
79
|
+
boundsJson: null,
|
|
80
|
+
bucketCountsJson: null,
|
|
81
|
+
extraJson: null,
|
|
82
|
+
exemplarsJson: null,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const countsForSignal = async (
|
|
86
|
+
db: SqlDb,
|
|
87
|
+
projectId: string,
|
|
88
|
+
table: string,
|
|
89
|
+
hourCutoffIso: string,
|
|
90
|
+
): Promise<{ propagated: number; missing: number }> => {
|
|
91
|
+
const row = await db
|
|
92
|
+
.prepare(
|
|
93
|
+
`SELECT
|
|
94
|
+
SUM(CASE WHEN interaction_id IS NOT NULL THEN 1 ELSE 0 END) AS propagated,
|
|
95
|
+
SUM(CASE WHEN interaction_id IS NULL THEN 1 ELSE 0 END) AS missing
|
|
96
|
+
FROM ${table}
|
|
97
|
+
WHERE project_id = ? AND received_at >= ?`,
|
|
98
|
+
)
|
|
99
|
+
.bind(projectId, hourCutoffIso)
|
|
100
|
+
.first<{ propagated: number | null; missing: number | null }>();
|
|
101
|
+
return {
|
|
102
|
+
propagated: row?.propagated ?? 0,
|
|
103
|
+
missing: row?.missing ?? 0,
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Aggregate propagation counts for a single project and write them as
|
|
109
|
+
* metric points. Idempotent per hour-bucket (each run produces a new
|
|
110
|
+
* point; series identity stays stable).
|
|
111
|
+
*/
|
|
112
|
+
export const aggregatePropagationForProject = async (
|
|
113
|
+
db: SqlDb,
|
|
114
|
+
projectId: string,
|
|
115
|
+
now: Date,
|
|
116
|
+
logger?: Logger,
|
|
117
|
+
): Promise<{ pointsWritten: number }> => {
|
|
118
|
+
const hourCutoff = new Date(now.getTime() - 60 * 60 * 1000).toISOString();
|
|
119
|
+
const tsNs = (BigInt(now.getTime()) * 1_000_000n).toString();
|
|
120
|
+
const points: DecodedMetricPoint[] = [];
|
|
121
|
+
|
|
122
|
+
for (const { signal, table } of SIGNALS) {
|
|
123
|
+
try {
|
|
124
|
+
const { propagated, missing } = await countsForSignal(
|
|
125
|
+
db,
|
|
126
|
+
projectId,
|
|
127
|
+
table,
|
|
128
|
+
hourCutoff,
|
|
129
|
+
);
|
|
130
|
+
points.push(buildPoint(signal, true, propagated, tsNs));
|
|
131
|
+
points.push(buildPoint(signal, false, missing, tsNs));
|
|
132
|
+
} catch (err) {
|
|
133
|
+
logger?.error("[propagation-metric] count failed", {
|
|
134
|
+
signal,
|
|
135
|
+
table,
|
|
136
|
+
projectId,
|
|
137
|
+
error: err instanceof Error ? err.message : String(err),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (points.length === 0) return { pointsWritten: 0 };
|
|
143
|
+
|
|
144
|
+
const store = new MetricsStore(db);
|
|
145
|
+
await store.ingestBatch({
|
|
146
|
+
projectId,
|
|
147
|
+
points,
|
|
148
|
+
receivedAt: now.toISOString(),
|
|
149
|
+
// Keep propagation metrics on the same retention as everything else.
|
|
150
|
+
expiresAt: new Date(now.getTime() + 72 * 60 * 60 * 1000).toISOString(),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return { pointsWritten: points.length };
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Run the aggregation across all known projects. Designed for a once-per-hour
|
|
158
|
+
* cron handler; cheap enough to run alongside retention cleanup.
|
|
159
|
+
*/
|
|
160
|
+
export const aggregatePropagation = async (
|
|
161
|
+
db: SqlDb,
|
|
162
|
+
now: Date,
|
|
163
|
+
logger?: Logger,
|
|
164
|
+
): Promise<{ projects: number; pointsWritten: number }> => {
|
|
165
|
+
const projects = await db
|
|
166
|
+
.prepare("SELECT id FROM projects")
|
|
167
|
+
.all<{ id: string }>();
|
|
168
|
+
|
|
169
|
+
let pointsWritten = 0;
|
|
170
|
+
for (const p of projects.results) {
|
|
171
|
+
const r = await aggregatePropagationForProject(db, p.id, now, logger);
|
|
172
|
+
pointsWritten += r.pointsWritten;
|
|
173
|
+
}
|
|
174
|
+
return { projects: projects.results.length, pointsWritten };
|
|
175
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { postgresDialect, sqliteDialect } from "./sql-db";
|
|
3
|
+
|
|
4
|
+
describe("SqlDialect", () => {
|
|
5
|
+
it("renders native SQLite expressions", () => {
|
|
6
|
+
expect(sqliteDialect.sinceHours("?")).toBe(
|
|
7
|
+
"datetime('now', '-' || ? || ' hours')",
|
|
8
|
+
);
|
|
9
|
+
expect(
|
|
10
|
+
sqliteDialect.jsonText("attributes_json", '$."llm.cost.total_usd"'),
|
|
11
|
+
).toBe(`json_extract(attributes_json, '$."llm.cost.total_usd"')`);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("renders native Postgres expressions without adapter regex translation", () => {
|
|
15
|
+
expect(postgresDialect.sinceHours("$1")).toBe(
|
|
16
|
+
"(CURRENT_TIMESTAMP - ($1::text || ' hours')::interval)",
|
|
17
|
+
);
|
|
18
|
+
expect(
|
|
19
|
+
postgresDialect.jsonText("attributes_json", '$."llm.cost.total_usd"'),
|
|
20
|
+
).toBe("(attributes_json::jsonb ->> 'llm.cost.total_usd')");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres adapter for the SqlDb seam (RFC 0008).
|
|
3
|
+
*
|
|
4
|
+
* Wraps `pg` (node-postgres) to expose the same `prepare → bind → first |
|
|
5
|
+
* all | run` chain as `D1Adapter`. Used by the Docker / standalone Node
|
|
6
|
+
* deployment of `apps/collector`; the Workers deployment continues to
|
|
7
|
+
* use `D1Adapter`.
|
|
8
|
+
*
|
|
9
|
+
* Notable differences from D1 handled here:
|
|
10
|
+
* - Placeholders: D1 uses `?`; Postgres uses `$1`, `$2`. We rewrite
|
|
11
|
+
* `?` to `$N` in `prepare`. Statements MUST use `?` for portability.
|
|
12
|
+
* - `INSERT … RETURNING`: D1 doesn't support RETURNING; Postgres does.
|
|
13
|
+
* Code that targets both MUST NOT rely on RETURNING.
|
|
14
|
+
* - `TEXT vs VARCHAR`: D1 is permissive; we use TEXT exclusively in
|
|
15
|
+
* the postgres migrations.
|
|
16
|
+
* - `meta.changes`: D1 reports affected row count via meta.changes;
|
|
17
|
+
* Postgres reports it via `result.rowCount`. We normalize.
|
|
18
|
+
*
|
|
19
|
+
* Connection pooling is the caller's job — pass a `pg.Pool`, not a
|
|
20
|
+
* one-shot Client. Each `prepare` checks out, runs the statement, and
|
|
21
|
+
* releases. Long-lived transactions need a different surface (not yet
|
|
22
|
+
* exposed; collector code today does not require them).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Pool, QueryResultRow } from "pg";
|
|
26
|
+
import { postgresDialect, type SqlDb, type SqlStatement } from "./sql-db";
|
|
27
|
+
|
|
28
|
+
export interface PostgresAdapterOptions {
|
|
29
|
+
/**
|
|
30
|
+
* Statement-level timeout in milliseconds. Defaults to 30s. Issued
|
|
31
|
+
* via `SET statement_timeout` at the start of each query (session-level;
|
|
32
|
+
* `SET LOCAL` outside a transaction would be a no-op).
|
|
33
|
+
*/
|
|
34
|
+
statementTimeoutMs?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class PostgresAdapter implements SqlDb {
|
|
38
|
+
readonly dialect = postgresDialect;
|
|
39
|
+
private readonly statementTimeoutMs: number;
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
private readonly pool: Pool,
|
|
43
|
+
options: PostgresAdapterOptions = {},
|
|
44
|
+
) {
|
|
45
|
+
const rawTimeout = options.statementTimeoutMs ?? 30_000;
|
|
46
|
+
this.statementTimeoutMs =
|
|
47
|
+
Number.isFinite(rawTimeout) && rawTimeout >= 0
|
|
48
|
+
? Math.trunc(rawTimeout)
|
|
49
|
+
: 30_000;
|
|
50
|
+
|
|
51
|
+
// Configure statement timeout once upon connection establishment
|
|
52
|
+
this.pool.on("connect", (client) => {
|
|
53
|
+
client
|
|
54
|
+
.query(`SET statement_timeout = ${this.statementTimeoutMs}`)
|
|
55
|
+
.catch(() => {});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
prepare(sql: string): SqlStatement {
|
|
60
|
+
return new PostgresStatement(
|
|
61
|
+
this.pool,
|
|
62
|
+
translateD1Sql(rewriteQuestionMarks(sql)),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async batch(
|
|
67
|
+
statements: SqlStatement[],
|
|
68
|
+
): Promise<Array<{ meta: { changes: number } }>> {
|
|
69
|
+
const client = await this.pool.connect();
|
|
70
|
+
try {
|
|
71
|
+
await client.query("BEGIN");
|
|
72
|
+
// SET LOCAL works here because it is inside the transaction.
|
|
73
|
+
await client.query(
|
|
74
|
+
`SET LOCAL statement_timeout = ${this.statementTimeoutMs}`,
|
|
75
|
+
);
|
|
76
|
+
const out: Array<{ meta: { changes: number } }> = [];
|
|
77
|
+
for (const s of statements) {
|
|
78
|
+
if (!(s instanceof PostgresStatement)) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
"PostgresAdapter.batch: statement was not produced by PostgresAdapter",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const r = await s.runOnClient(client);
|
|
84
|
+
out.push(r);
|
|
85
|
+
}
|
|
86
|
+
await client.query("COMMIT");
|
|
87
|
+
return out;
|
|
88
|
+
} catch (err) {
|
|
89
|
+
await client.query("ROLLBACK").catch(() => {});
|
|
90
|
+
throw err;
|
|
91
|
+
} finally {
|
|
92
|
+
client.release();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
class PostgresStatement implements SqlStatement {
|
|
98
|
+
private bound: unknown[] = [];
|
|
99
|
+
|
|
100
|
+
constructor(
|
|
101
|
+
private readonly pool: Pool,
|
|
102
|
+
private readonly sql: string,
|
|
103
|
+
) {}
|
|
104
|
+
|
|
105
|
+
bind(...args: unknown[]): SqlStatement {
|
|
106
|
+
this.bound = args;
|
|
107
|
+
return this;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async first<T = Record<string, unknown>>(): Promise<T | null> {
|
|
111
|
+
const result = await this.exec<T & QueryResultRow>();
|
|
112
|
+
return (result.rows[0] as T | undefined) ?? null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async all<T = Record<string, unknown>>(): Promise<{ results: T[] }> {
|
|
116
|
+
const result = await this.exec<T & QueryResultRow>();
|
|
117
|
+
return { results: result.rows as unknown as T[] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async run(): Promise<{ meta: { changes: number } }> {
|
|
121
|
+
const result = await this.exec();
|
|
122
|
+
return { meta: { changes: result.rowCount ?? 0 } };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Internal — used by batch() to run inside an already-checked-out
|
|
126
|
+
// client (so the BEGIN/COMMIT span the whole batch).
|
|
127
|
+
async runOnClient(client: {
|
|
128
|
+
query: (
|
|
129
|
+
text: string,
|
|
130
|
+
values?: unknown[],
|
|
131
|
+
) => Promise<{ rowCount: number | null }>;
|
|
132
|
+
}): Promise<{ meta: { changes: number } }> {
|
|
133
|
+
const r = await client.query(this.sql, this.bound);
|
|
134
|
+
return { meta: { changes: r.rowCount ?? 0 } };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async exec<T extends QueryResultRow = QueryResultRow>(): Promise<{
|
|
138
|
+
rows: T[];
|
|
139
|
+
rowCount: number | null;
|
|
140
|
+
}> {
|
|
141
|
+
const client = await this.pool.connect();
|
|
142
|
+
try {
|
|
143
|
+
const r = await client.query<T>(this.sql, this.bound);
|
|
144
|
+
return { rows: r.rows, rowCount: r.rowCount };
|
|
145
|
+
} finally {
|
|
146
|
+
client.release();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// D1 → Postgres placeholder rewrite. Walks the string once; tracks
|
|
152
|
+
// whether we're inside a single-quoted string literal so that '?' inside
|
|
153
|
+
// strings isn't rewritten. Doesn't handle dollar-quoted strings — none
|
|
154
|
+
// of the collector's SQL uses them.
|
|
155
|
+
const rewriteQuestionMarks = (sql: string): string => {
|
|
156
|
+
let out = "";
|
|
157
|
+
let inString = false;
|
|
158
|
+
let n = 0;
|
|
159
|
+
for (let i = 0; i < sql.length; i++) {
|
|
160
|
+
const ch = sql[i];
|
|
161
|
+
if (ch === "'") {
|
|
162
|
+
inString = !inString;
|
|
163
|
+
out += ch;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (ch === "?" && !inString) {
|
|
167
|
+
n += 1;
|
|
168
|
+
out += `$${n}`;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
out += ch;
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const translateD1Sql = (sql: string): string => {
|
|
177
|
+
let out = sql;
|
|
178
|
+
const hadInsertOrIgnore = /\bINSERT\s+OR\s+IGNORE\s+INTO\b/i.test(out);
|
|
179
|
+
|
|
180
|
+
out = out.replace(/\bINSERT\s+OR\s+IGNORE\s+INTO\b/gi, "INSERT INTO");
|
|
181
|
+
|
|
182
|
+
out = out.replace(
|
|
183
|
+
/datetime\('now',\s*'-'\s*\|\|\s*(\$\d+)\s*\|\|\s*'\s*hours'\)/gi,
|
|
184
|
+
"(CURRENT_TIMESTAMP - ($1::text || ' hours')::interval)",
|
|
185
|
+
);
|
|
186
|
+
out = out.replace(
|
|
187
|
+
/datetime\('now',\s*'-'\s*\|\|\s*(\$\d+)\s*\|\|\s*'\s*minutes'\)/gi,
|
|
188
|
+
"(CURRENT_TIMESTAMP - ($1::text || ' minutes')::interval)",
|
|
189
|
+
);
|
|
190
|
+
out = out.replace(
|
|
191
|
+
/datetime\('now',\s*'-(\d+)\s+(hour|hours|minute|minutes|day|days)'\)/gi,
|
|
192
|
+
"CURRENT_TIMESTAMP - INTERVAL '$1 $2'",
|
|
193
|
+
);
|
|
194
|
+
out = out.replace(/datetime\('now'\)/gi, "CURRENT_TIMESTAMP");
|
|
195
|
+
|
|
196
|
+
out = out.replace(
|
|
197
|
+
/json_extract\(([^,()]+),\s*'\$\.(?:"([^"]+)"|([A-Za-z0-9_\\.\\\\]+))'\)/gi,
|
|
198
|
+
(
|
|
199
|
+
_match,
|
|
200
|
+
expr: string,
|
|
201
|
+
quotedKey: string | undefined,
|
|
202
|
+
bareKey: string | undefined,
|
|
203
|
+
) => {
|
|
204
|
+
const key = (quotedKey ?? bareKey ?? "").replace(
|
|
205
|
+
/\\\\u002E|\\u002E/g,
|
|
206
|
+
".",
|
|
207
|
+
);
|
|
208
|
+
return `(${expr.trim()}::jsonb ->> '${key.replace(/'/g, "''")}')`;
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
out = out.replace(
|
|
213
|
+
/strftime\('%Y-%m-%dT%H:%M:00Z',\s*([^)]+)\)/gi,
|
|
214
|
+
"to_char(date_trunc('minute', $1::timestamp), 'YYYY-MM-DD\"T\"HH24:MI:00\"Z\"')",
|
|
215
|
+
);
|
|
216
|
+
out = out.replace(
|
|
217
|
+
/strftime\('%Y-%m-%dT%H:00:00Z',\s*([^)]+)\)/gi,
|
|
218
|
+
"to_char(date_trunc('hour', $1::timestamp), 'YYYY-MM-DD\"T\"HH24:00:00\"Z\"')",
|
|
219
|
+
);
|
|
220
|
+
out = out.replace(
|
|
221
|
+
/strftime\('%s',\s*([^)]+)\)/gi,
|
|
222
|
+
"EXTRACT(EPOCH FROM $1::timestamp)",
|
|
223
|
+
);
|
|
224
|
+
if (hadInsertOrIgnore && /\bON\s+CONFLICT\b/i.test(out) === false) {
|
|
225
|
+
out = out.replace(/(;?\s*)$/, " ON CONFLICT DO NOTHING$1");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return out;
|
|
229
|
+
};
|