@graphoria/server 0.4.0 → 0.5.0
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/README.md +4 -0
- package/dist/src/analyzeQuery/depthLimit.d.ts +1 -13
- package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
- package/dist/src/authentication/capabilities.d.ts +1 -1
- package/dist/src/authentication/capabilities.d.ts.map +1 -1
- package/dist/src/authentication/tokenRepository.d.ts.map +1 -1
- package/dist/src/config/types/db.d.ts +25 -0
- package/dist/src/config/types/db.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/index.d.ts +40 -0
- package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +20 -0
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
- package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
- package/dist/src/configuration/index.d.ts +30 -0
- package/dist/src/configuration/index.d.ts.map +1 -1
- package/dist/src/console/api.d.ts.map +1 -1
- package/dist/src/cron/index.d.ts.map +1 -1
- package/dist/src/databases/common.d.ts +20 -6
- package/dist/src/databases/common.d.ts.map +1 -1
- package/dist/src/databases/core/executor.d.ts +4 -6
- package/dist/src/databases/core/executor.d.ts.map +1 -1
- package/dist/src/databases/core/function-mapping.d.ts +1 -2
- package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
- package/dist/src/databases/core/query-builder.d.ts +5 -0
- package/dist/src/databases/core/query-builder.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/getStructure.d.ts +5 -0
- package/dist/src/databases/engines/mssql/getStructure.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/query/index.d.ts +2 -2
- package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/getStructure.d.ts +5 -0
- package/dist/src/databases/engines/mysql/getStructure.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/query/index.d.ts +2 -2
- package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/getStructure.d.ts +5 -0
- package/dist/src/databases/engines/postgresql/getStructure.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/query/index.d.ts +2 -2
- package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
- package/dist/src/databases/high-level-operations.d.ts +5 -0
- package/dist/src/databases/high-level-operations.d.ts.map +1 -1
- package/dist/src/databases/metadata/structure.d.ts +5 -0
- package/dist/src/databases/metadata/structure.d.ts.map +1 -1
- package/dist/src/databases/transformers/data-transformers.d.ts +30 -0
- package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/logging/slowQuery.d.ts +29 -0
- package/dist/src/logging/slowQuery.d.ts.map +1 -0
- package/dist/src/logging/slowQuery.test.d.ts +2 -0
- package/dist/src/logging/slowQuery.test.d.ts.map +1 -0
- package/dist/src/observability/health.d.ts +23 -0
- package/dist/src/observability/health.d.ts.map +1 -0
- package/dist/src/observability/health.test.d.ts +2 -0
- package/dist/src/observability/health.test.d.ts.map +1 -0
- package/dist/src/observability/httpMetrics.d.ts +7 -0
- package/dist/src/observability/httpMetrics.d.ts.map +1 -0
- package/dist/src/observability/httpMetrics.test.d.ts +2 -0
- package/dist/src/observability/httpMetrics.test.d.ts.map +1 -0
- package/dist/src/observability/httpTracing.d.ts +11 -0
- package/dist/src/observability/httpTracing.d.ts.map +1 -0
- package/dist/src/observability/httpTracing.test.d.ts +2 -0
- package/dist/src/observability/httpTracing.test.d.ts.map +1 -0
- package/dist/src/observability/metrics.d.ts +81 -0
- package/dist/src/observability/metrics.d.ts.map +1 -0
- package/dist/src/observability/metrics.perf.test.d.ts +2 -0
- package/dist/src/observability/metrics.perf.test.d.ts.map +1 -0
- package/dist/src/observability/metrics.test.d.ts +2 -0
- package/dist/src/observability/metrics.test.d.ts.map +1 -0
- package/dist/src/observability/metricsRoute.d.ts +19 -0
- package/dist/src/observability/metricsRoute.d.ts.map +1 -0
- package/dist/src/observability/metricsRoute.test.d.ts +2 -0
- package/dist/src/observability/metricsRoute.test.d.ts.map +1 -0
- package/dist/src/observability/tracing.d.ts +92 -0
- package/dist/src/observability/tracing.d.ts.map +1 -0
- package/dist/src/observability/tracing.perf.test.d.ts +2 -0
- package/dist/src/observability/tracing.perf.test.d.ts.map +1 -0
- package/dist/src/observability/tracing.test.d.ts +2 -0
- package/dist/src/observability/tracing.test.d.ts.map +1 -0
- package/dist/src/queues/kafka.d.ts +6 -0
- package/dist/src/queues/kafka.d.ts.map +1 -1
- package/dist/src/queues/kafka.test.d.ts +2 -0
- package/dist/src/queues/kafka.test.d.ts.map +1 -0
- package/dist/src/queues/rabbitmq.d.ts.map +1 -1
- package/dist/src/remoteREST/proxy.d.ts.map +1 -1
- package/dist/src/remoteSchemas/proxy.d.ts.map +1 -1
- package/dist/src/singletons/cache/redisClient.d.ts +2 -2
- package/dist/src/singletons/cache/redisClient.d.ts.map +1 -1
- package/dist/src/singletons/databases.d.ts +1 -0
- package/dist/src/singletons/databases.d.ts.map +1 -1
- package/dist/src/singletons/env.d.ts +29 -0
- package/dist/src/singletons/env.d.ts.map +1 -1
- package/dist/src/singletons/queues.d.ts +1 -0
- package/dist/src/singletons/queues.d.ts.map +1 -1
- package/dist/src/subscriptions/strategies/database.d.ts.map +1 -1
- package/dist/src/subscriptions/utils/polling.d.ts +1 -0
- package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
- package/dist/src/subscriptions/utils/polling.test.d.ts +2 -0
- package/dist/src/subscriptions/utils/polling.test.d.ts.map +1 -0
- package/dist/src/types/env.d.ts +89 -0
- package/dist/src/types/env.d.ts.map +1 -1
- package/dist/src/types/zod/configuration.d.ts +25 -0
- package/dist/src/types/zod/configuration.d.ts.map +1 -1
- package/dist/src/types/zod/db.d.ts +85 -0
- package/dist/src/types/zod/db.d.ts.map +1 -1
- package/dist/src/utils/rateLimit.d.ts.map +1 -1
- package/dist/src/utils/redis.d.ts +17 -0
- package/dist/src/utils/redis.d.ts.map +1 -0
- package/dist/src/utils/redis.test.d.ts +2 -0
- package/dist/src/utils/redis.test.d.ts.map +1 -0
- package/dist/src/utils/responses.d.ts +3 -0
- package/dist/src/utils/responses.d.ts.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -1
- package/playgrounds/console/index.html +8 -8
- package/playgrounds/graphiql/index.html +114 -114
- package/playgrounds/scalar/index.html +1 -1
- package/src/analyzeQuery/depthLimit.ts +15 -5
- package/src/authentication/capabilities.ts +3 -1
- package/src/authentication/tokenRepository.ts +2 -3
- package/src/config/types/db.ts +16 -2
- package/src/configuration/gql/handleGraphQLRequestFactory.ts +233 -152
- package/src/console/api.ts +1 -6
- package/src/cron/index.ts +59 -36
- package/src/databases/common.ts +109 -10
- package/src/databases/core/executor.ts +96 -18
- package/src/databases/core/function-mapping.ts +1 -1
- package/src/databases/engines/mssql/query/index.ts +4 -1
- package/src/databases/engines/mysql/query/index.ts +5 -2
- package/src/databases/engines/postgresql/query/index.ts +5 -2
- package/src/databases/high-level-operations.ts +44 -2
- package/src/index.ts +147 -62
- package/src/logging/slowQuery.ts +59 -0
- package/src/observability/health.ts +67 -0
- package/src/observability/httpMetrics.ts +37 -0
- package/src/observability/httpTracing.ts +48 -0
- package/src/observability/metrics.ts +243 -0
- package/src/observability/metricsRoute.ts +33 -0
- package/src/observability/tracing.ts +421 -0
- package/src/queues/kafka.ts +45 -2
- package/src/queues/rabbitmq.ts +37 -1
- package/src/remoteREST/proxy.ts +17 -0
- package/src/remoteSchemas/proxy.ts +17 -0
- package/src/singletons/cache/redisClient.ts +4 -5
- package/src/singletons/databases.ts +5 -0
- package/src/singletons/queues.ts +9 -3
- package/src/subscriptions/strategies/database.ts +2 -0
- package/src/subscriptions/utils/polling.ts +80 -21
- package/src/types/env.ts +27 -0
- package/src/utils/rateLimit.ts +6 -1
- package/src/utils/redis.ts +71 -0
- package/src/utils/responses.ts +6 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
type MetricDefinition = {
|
|
2
|
+
type: "counter" | "histogram";
|
|
3
|
+
help: string;
|
|
4
|
+
/** Declared sorted, so a rendered series lists its labels in a stable order. */
|
|
5
|
+
labelNames: readonly string[];
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const METRICS = {
|
|
9
|
+
graphoria_graphql_operations_total: {
|
|
10
|
+
type: "counter",
|
|
11
|
+
help: "GraphQL operations executed, by outcome.",
|
|
12
|
+
labelNames: ["operation", "outcome", "role", "type"],
|
|
13
|
+
},
|
|
14
|
+
graphoria_graphql_operation_duration_seconds: {
|
|
15
|
+
type: "histogram",
|
|
16
|
+
help: "Time spent handling a GraphQL operation, in seconds.",
|
|
17
|
+
labelNames: ["operation", "role", "type"],
|
|
18
|
+
},
|
|
19
|
+
graphoria_http_requests_total: {
|
|
20
|
+
type: "counter",
|
|
21
|
+
help: "HTTP requests answered by the GraphQL and REST routes, by status.",
|
|
22
|
+
labelNames: ["method", "route", "status"],
|
|
23
|
+
},
|
|
24
|
+
graphoria_http_request_duration_seconds: {
|
|
25
|
+
type: "histogram",
|
|
26
|
+
help: "Time spent answering an HTTP request, in seconds.",
|
|
27
|
+
labelNames: ["method", "route"],
|
|
28
|
+
},
|
|
29
|
+
graphoria_cron_runs_total: {
|
|
30
|
+
type: "counter",
|
|
31
|
+
help: "Cron ticks that ran to completion or threw, by job.",
|
|
32
|
+
labelNames: ["job", "outcome"],
|
|
33
|
+
},
|
|
34
|
+
graphoria_cron_run_duration_seconds: {
|
|
35
|
+
type: "histogram",
|
|
36
|
+
help: "Time spent in a cron tick, in seconds.",
|
|
37
|
+
labelNames: ["job"],
|
|
38
|
+
},
|
|
39
|
+
graphoria_queue_messages_published_total: {
|
|
40
|
+
type: "counter",
|
|
41
|
+
help: "Messages handed to a broker, by publisher and outcome.",
|
|
42
|
+
labelNames: ["broker", "outcome", "publisher"],
|
|
43
|
+
},
|
|
44
|
+
graphoria_queue_messages_consumed_total: {
|
|
45
|
+
type: "counter",
|
|
46
|
+
help: "Messages taken from a broker, by consumer and outcome.",
|
|
47
|
+
labelNames: ["broker", "consumer", "outcome", "queue"],
|
|
48
|
+
},
|
|
49
|
+
graphoria_rate_limit_rejections_total: {
|
|
50
|
+
type: "counter",
|
|
51
|
+
help: "Requests refused by the rate limiter, by the role the ceiling was read from.",
|
|
52
|
+
labelNames: ["role"],
|
|
53
|
+
},
|
|
54
|
+
graphoria_graphql_rejections_total: {
|
|
55
|
+
type: "counter",
|
|
56
|
+
help: "GraphQL operations rejected before execution.",
|
|
57
|
+
labelNames: ["reason"],
|
|
58
|
+
},
|
|
59
|
+
} as const satisfies Record<string, MetricDefinition>;
|
|
60
|
+
|
|
61
|
+
export type MetricName = keyof typeof METRICS;
|
|
62
|
+
|
|
63
|
+
export type Labels = Record<string, string>;
|
|
64
|
+
|
|
65
|
+
export type Registry = {
|
|
66
|
+
inc(metric: MetricName, labels: Labels, value?: number): void;
|
|
67
|
+
observe(metric: MetricName, labels: Labels, seconds: number): void;
|
|
68
|
+
render(): string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type RegistryOptions = {
|
|
72
|
+
/** Distinct values the caller-supplied `operation` label may take, per metric. */
|
|
73
|
+
maxOperationLabels?: number;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10] as const;
|
|
77
|
+
|
|
78
|
+
const SEPARATOR = "\u0000";
|
|
79
|
+
|
|
80
|
+
const OVERFLOW_LABEL = "other";
|
|
81
|
+
|
|
82
|
+
const DEFAULT_MAX_OPERATION_LABELS = 200;
|
|
83
|
+
|
|
84
|
+
type HistogramSeries = { counts: number[]; sum: number; count: number };
|
|
85
|
+
|
|
86
|
+
const escapeLabelValue = (value: string) =>
|
|
87
|
+
value.replaceAll("\\", "\\\\").replaceAll("\n", "\\n").replaceAll('"', '\\"');
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A metric store with no dependencies. The `operation` label is the only one a
|
|
91
|
+
* caller controls, so it is the only one capped: past the cap every new name
|
|
92
|
+
* collapses into `other` rather than minting a series the scrape has to carry
|
|
93
|
+
* forever.
|
|
94
|
+
*/
|
|
95
|
+
export const createRegistry = ({
|
|
96
|
+
maxOperationLabels = DEFAULT_MAX_OPERATION_LABELS,
|
|
97
|
+
}: RegistryOptions = {}): Registry => {
|
|
98
|
+
const counters = new Map<MetricName, Map<string, number>>();
|
|
99
|
+
const histograms = new Map<MetricName, Map<string, HistogramSeries>>();
|
|
100
|
+
const knownOperations = new Map<MetricName, Set<string>>();
|
|
101
|
+
|
|
102
|
+
const boundOperation = (metric: MetricName, operation: string) => {
|
|
103
|
+
let known = knownOperations.get(metric);
|
|
104
|
+
if (!known) {
|
|
105
|
+
known = new Set();
|
|
106
|
+
knownOperations.set(metric, known);
|
|
107
|
+
}
|
|
108
|
+
if (known.has(operation)) return operation;
|
|
109
|
+
if (known.size >= maxOperationLabels) return OVERFLOW_LABEL;
|
|
110
|
+
known.add(operation);
|
|
111
|
+
return operation;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const keyOf = (metric: MetricName, labels: Labels) =>
|
|
115
|
+
METRICS[metric].labelNames
|
|
116
|
+
.map((name) => {
|
|
117
|
+
const value = labels[name] ?? "";
|
|
118
|
+
return name === "operation" ? boundOperation(metric, value) : value;
|
|
119
|
+
})
|
|
120
|
+
.join(SEPARATOR);
|
|
121
|
+
|
|
122
|
+
const renderLabels = (metric: MetricName, key: string, extra?: string) => {
|
|
123
|
+
const values = key.split(SEPARATOR);
|
|
124
|
+
const pairs = METRICS[metric].labelNames.map(
|
|
125
|
+
(name, index) => `${name}="${escapeLabelValue(values[index] ?? "")}"`,
|
|
126
|
+
);
|
|
127
|
+
if (extra) pairs.push(extra);
|
|
128
|
+
return pairs.length > 0 ? `{${pairs.join(",")}}` : "";
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const seriesOf = <T>(store: Map<MetricName, Map<string, T>>, metric: MetricName) => {
|
|
132
|
+
let series = store.get(metric);
|
|
133
|
+
if (!series) {
|
|
134
|
+
series = new Map();
|
|
135
|
+
store.set(metric, series);
|
|
136
|
+
}
|
|
137
|
+
return series;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const renderHeader = (lines: string[], metric: MetricName) => {
|
|
141
|
+
const definition = METRICS[metric];
|
|
142
|
+
lines.push(`# HELP ${metric} ${definition.help}`, `# TYPE ${metric} ${definition.type}`);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
inc: (metric, labels, value = 1) => {
|
|
147
|
+
const series = seriesOf(counters, metric);
|
|
148
|
+
const key = keyOf(metric, labels);
|
|
149
|
+
series.set(key, (series.get(key) ?? 0) + value);
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
observe: (metric, labels, seconds) => {
|
|
153
|
+
const series = seriesOf(histograms, metric);
|
|
154
|
+
const key = keyOf(metric, labels);
|
|
155
|
+
let entry = series.get(key);
|
|
156
|
+
if (!entry) {
|
|
157
|
+
entry = { counts: new Array(BUCKETS.length).fill(0), sum: 0, count: 0 };
|
|
158
|
+
series.set(key, entry);
|
|
159
|
+
}
|
|
160
|
+
entry.sum += seconds;
|
|
161
|
+
entry.count += 1;
|
|
162
|
+
for (let index = 0; index < BUCKETS.length; index++) {
|
|
163
|
+
if (seconds <= BUCKETS[index]!) entry.counts[index]! += 1;
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
render: () => {
|
|
168
|
+
const lines: string[] = [];
|
|
169
|
+
|
|
170
|
+
for (const [metric, series] of counters) {
|
|
171
|
+
if (series.size === 0) continue;
|
|
172
|
+
renderHeader(lines, metric);
|
|
173
|
+
for (const [key, value] of series) {
|
|
174
|
+
lines.push(`${metric}${renderLabels(metric, key)} ${value}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const [metric, series] of histograms) {
|
|
179
|
+
if (series.size === 0) continue;
|
|
180
|
+
renderHeader(lines, metric);
|
|
181
|
+
for (const [key, entry] of series) {
|
|
182
|
+
for (let index = 0; index < BUCKETS.length; index++) {
|
|
183
|
+
lines.push(
|
|
184
|
+
`${metric}_bucket${renderLabels(metric, key, `le="${BUCKETS[index]}"`)} ${entry.counts[index]}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
lines.push(
|
|
188
|
+
`${metric}_bucket${renderLabels(metric, key, 'le="+Inf"')} ${entry.count}`,
|
|
189
|
+
`${metric}_sum${renderLabels(metric, key)} ${entry.sum}`,
|
|
190
|
+
`${metric}_count${renderLabels(metric, key)} ${entry.count}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return lines.length > 0 ? `${lines.join("\n")}\n` : "";
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Off until boot turns it on, so an executor or a handler used outside a booted
|
|
202
|
+
* server records nothing. Every call site goes through the helpers below, whose
|
|
203
|
+
* first act is this check — a disabled process pays one branch and allocates
|
|
204
|
+
* nothing.
|
|
205
|
+
*/
|
|
206
|
+
let enabled = false;
|
|
207
|
+
|
|
208
|
+
let options: RegistryOptions = {};
|
|
209
|
+
|
|
210
|
+
let override: Registry | null = null;
|
|
211
|
+
let instance: Registry | null = null;
|
|
212
|
+
|
|
213
|
+
export const configureMetrics = (settings: { enabled: boolean } & RegistryOptions): void => {
|
|
214
|
+
enabled = settings.enabled;
|
|
215
|
+
options = { maxOperationLabels: settings.maxOperationLabels };
|
|
216
|
+
instance = null;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/** Test seam: pass `null` to drop the process registry and its recorded series. */
|
|
220
|
+
export const setMetricsRegistry = (registry: Registry | null): void => {
|
|
221
|
+
override = registry;
|
|
222
|
+
instance = null;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const registry = (): Registry => {
|
|
226
|
+
if (override) return override;
|
|
227
|
+
if (!instance) instance = createRegistry(options);
|
|
228
|
+
return instance;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
export const isMetricsEnabled = (): boolean => enabled;
|
|
232
|
+
|
|
233
|
+
export const incMetric = (metric: MetricName, labels: Labels, value = 1): void => {
|
|
234
|
+
if (!enabled) return;
|
|
235
|
+
registry().inc(metric, labels, value);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export const observeMetric = (metric: MetricName, labels: Labels, seconds: number): void => {
|
|
239
|
+
if (!enabled) return;
|
|
240
|
+
registry().observe(metric, labels, seconds);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
export const renderMetrics = (): string => registry().render();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CapabilityGrant } from "../authentication/capabilities";
|
|
2
|
+
|
|
3
|
+
import { S401 } from "../utils/responses";
|
|
4
|
+
|
|
5
|
+
export type MetricsRouteOptions = {
|
|
6
|
+
path: string;
|
|
7
|
+
/** The header a scoped credential rides in — the admin-secret header. */
|
|
8
|
+
secretHeader: string;
|
|
9
|
+
authorize: (candidate: string | null) => CapabilityGrant | null;
|
|
10
|
+
render: () => string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A scrape is gated because the exposition names operations, roles and
|
|
17
|
+
* databases. It is deliberately not rate limited: a scrape interval is fixed
|
|
18
|
+
* and counting it against a caller ceiling would drop samples.
|
|
19
|
+
*/
|
|
20
|
+
export const createMetricsRoute = ({
|
|
21
|
+
path,
|
|
22
|
+
secretHeader,
|
|
23
|
+
authorize,
|
|
24
|
+
render,
|
|
25
|
+
}: MetricsRouteOptions) => ({
|
|
26
|
+
[path]: {
|
|
27
|
+
GET: (req: Request) => {
|
|
28
|
+
if (!authorize(req.headers.get(secretHeader))) return new S401({ error: "Unauthorized" });
|
|
29
|
+
|
|
30
|
+
return new Response(render(), { headers: { "content-type": CONTENT_TYPE } });
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
});
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
import type { Logger } from "pino";
|
|
4
|
+
|
|
5
|
+
import { version } from "../../package.json";
|
|
6
|
+
import { logger } from "../logging";
|
|
7
|
+
|
|
8
|
+
export type SpanKind = "internal" | "server" | "client" | "producer" | "consumer";
|
|
9
|
+
|
|
10
|
+
export type SpanStatus = "unset" | "ok" | "error";
|
|
11
|
+
|
|
12
|
+
export type AttributeValue = string | number | boolean;
|
|
13
|
+
|
|
14
|
+
export type Attributes = Record<string, AttributeValue | undefined>;
|
|
15
|
+
|
|
16
|
+
/** What rides in the async context: three scalars, never the span itself. */
|
|
17
|
+
export type SpanContext = { traceId: string; spanId: string; sampled: boolean };
|
|
18
|
+
|
|
19
|
+
export type SpanOptions = {
|
|
20
|
+
kind?: SpanKind;
|
|
21
|
+
attributes?: Attributes;
|
|
22
|
+
/** Overrides the active context — the inbound `traceparent`, or a deliberate root. */
|
|
23
|
+
parent?: SpanContext | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type Span = {
|
|
27
|
+
readonly context: SpanContext;
|
|
28
|
+
setAttribute(key: string, value: AttributeValue | undefined): void;
|
|
29
|
+
setStatus(status: SpanStatus, message?: string): void;
|
|
30
|
+
recordError(error: unknown): void;
|
|
31
|
+
end(): void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type FinishedSpan = {
|
|
35
|
+
traceId: string;
|
|
36
|
+
spanId: string;
|
|
37
|
+
parentSpanId?: string;
|
|
38
|
+
name: string;
|
|
39
|
+
kind: SpanKind;
|
|
40
|
+
startTimeUnixNano: string;
|
|
41
|
+
endTimeUnixNano: string;
|
|
42
|
+
attributes: Attributes;
|
|
43
|
+
status: SpanStatus;
|
|
44
|
+
statusMessage?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type Tracer = {
|
|
48
|
+
startSpan(name: string, options?: SpanOptions): Span;
|
|
49
|
+
/** Runs `fn` with `span` active, ends it, and marks it errored if `fn` throws. */
|
|
50
|
+
withSpan<T>(name: string, options: SpanOptions, fn: (span: Span) => T | Promise<T>): Promise<T>;
|
|
51
|
+
active(): SpanContext | undefined;
|
|
52
|
+
flush(): Promise<void>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type TracerOptions = {
|
|
56
|
+
endpoint: string;
|
|
57
|
+
/** `k=v,k2=v2`, sent on every export — an API key for a hosted collector. */
|
|
58
|
+
headers?: string;
|
|
59
|
+
serviceName: string;
|
|
60
|
+
serviceVersion: string;
|
|
61
|
+
sampleRatio?: number;
|
|
62
|
+
maxBatch?: number;
|
|
63
|
+
maxQueue?: number;
|
|
64
|
+
/** `0` starts no timer, which is how a test drives the exporter by hand. */
|
|
65
|
+
exportIntervalMs?: number;
|
|
66
|
+
/** Test seam: where an export failure and a queue overflow are reported. */
|
|
67
|
+
log?: Pick<Logger, "warn">;
|
|
68
|
+
/** Test seam: the transport. Production POSTs with `fetch`. */
|
|
69
|
+
send?: (
|
|
70
|
+
url: string,
|
|
71
|
+
init: { method: string; headers: Record<string, string>; body: string },
|
|
72
|
+
) => Promise<void>;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const MAX_BATCH = 512;
|
|
76
|
+
const MAX_QUEUE = 2048;
|
|
77
|
+
const EXPORT_INTERVAL_MS = 5000;
|
|
78
|
+
|
|
79
|
+
const KIND_CODES: Record<SpanKind, number> = {
|
|
80
|
+
internal: 1,
|
|
81
|
+
server: 2,
|
|
82
|
+
client: 3,
|
|
83
|
+
producer: 4,
|
|
84
|
+
consumer: 5,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const STATUS_CODES: Record<SpanStatus, number> = { unset: 0, ok: 1, error: 2 };
|
|
88
|
+
|
|
89
|
+
const ZERO_TRACE_ID = "0".repeat(32);
|
|
90
|
+
const ZERO_SPAN_ID = "0".repeat(16);
|
|
91
|
+
|
|
92
|
+
const TRACEPARENT = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
93
|
+
|
|
94
|
+
// A span is minted per statement, so the id path is on the request's critical
|
|
95
|
+
// path: a lookup table beats formatting each byte.
|
|
96
|
+
const HEX = Array.from({ length: 256 }, (_, byte) => byte.toString(16).padStart(2, "0"));
|
|
97
|
+
|
|
98
|
+
const hex = (bytes: number) => {
|
|
99
|
+
const buffer = crypto.getRandomValues(new Uint8Array(bytes));
|
|
100
|
+
let id = "";
|
|
101
|
+
for (let index = 0; index < bytes; index++) id += HEX[buffer[index]!];
|
|
102
|
+
return id;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// An all-zero id is invalid per the W3C spec and a collector rejects the span,
|
|
106
|
+
// so the (vanishing) draw is re-rolled rather than exported.
|
|
107
|
+
const newTraceId = () => {
|
|
108
|
+
let id = hex(16);
|
|
109
|
+
while (id === ZERO_TRACE_ID) id = hex(16);
|
|
110
|
+
return id;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const newSpanId = () => {
|
|
114
|
+
let id = hex(8);
|
|
115
|
+
while (id === ZERO_SPAN_ID) id = hex(8);
|
|
116
|
+
return id;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const parseTraceparent = (header: string | null | undefined): SpanContext | undefined => {
|
|
120
|
+
if (!header) return undefined;
|
|
121
|
+
const match = TRACEPARENT.exec(header);
|
|
122
|
+
if (!match) return undefined;
|
|
123
|
+
const [, traceId, spanId, flags] = match as unknown as [string, string, string, string];
|
|
124
|
+
if (traceId === ZERO_TRACE_ID || spanId === ZERO_SPAN_ID) return undefined;
|
|
125
|
+
return { traceId, spanId, sampled: (Number.parseInt(flags, 16) & 1) === 1 };
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const formatTraceparent = (context: SpanContext): string =>
|
|
129
|
+
`00-${context.traceId}-${context.spanId}-${context.sampled ? "01" : "00"}`;
|
|
130
|
+
|
|
131
|
+
const parseHeaders = (headers: string): Record<string, string> => {
|
|
132
|
+
const parsed: Record<string, string> = {};
|
|
133
|
+
for (const entry of headers.split(",")) {
|
|
134
|
+
const index = entry.indexOf("=");
|
|
135
|
+
if (index <= 0) continue;
|
|
136
|
+
const key = entry.slice(0, index).trim();
|
|
137
|
+
if (key) parsed[key] = entry.slice(index + 1).trim();
|
|
138
|
+
}
|
|
139
|
+
return parsed;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
type AnyValue =
|
|
143
|
+
| { stringValue: string }
|
|
144
|
+
| { boolValue: boolean }
|
|
145
|
+
| { intValue: string }
|
|
146
|
+
| { doubleValue: number };
|
|
147
|
+
|
|
148
|
+
const renderAttributes = (attributes: Attributes) =>
|
|
149
|
+
Object.entries(attributes).flatMap<{ key: string; value: AnyValue }>(([key, value]) => {
|
|
150
|
+
if (value === undefined) return [];
|
|
151
|
+
if (typeof value === "string") return [{ key, value: { stringValue: value } }];
|
|
152
|
+
if (typeof value === "boolean") return [{ key, value: { boolValue: value } }];
|
|
153
|
+
// OTLP/JSON carries an int64 as a string; a fractional number is a double.
|
|
154
|
+
return [
|
|
155
|
+
{
|
|
156
|
+
key,
|
|
157
|
+
value: Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value },
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const renderSpan = (span: FinishedSpan) => ({
|
|
163
|
+
traceId: span.traceId,
|
|
164
|
+
spanId: span.spanId,
|
|
165
|
+
...(span.parentSpanId ? { parentSpanId: span.parentSpanId } : {}),
|
|
166
|
+
name: span.name,
|
|
167
|
+
kind: KIND_CODES[span.kind],
|
|
168
|
+
startTimeUnixNano: span.startTimeUnixNano,
|
|
169
|
+
endTimeUnixNano: span.endTimeUnixNano,
|
|
170
|
+
attributes: renderAttributes(span.attributes),
|
|
171
|
+
status: {
|
|
172
|
+
code: STATUS_CODES[span.status],
|
|
173
|
+
...(span.statusMessage ? { message: span.statusMessage } : {}),
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const renderPayload = (spans: FinishedSpan[], serviceName: string, serviceVersion: string) => ({
|
|
178
|
+
resourceSpans: [
|
|
179
|
+
{
|
|
180
|
+
resource: {
|
|
181
|
+
attributes: [
|
|
182
|
+
{ key: "service.name", value: { stringValue: serviceName } },
|
|
183
|
+
{ key: "service.version", value: { stringValue: serviceVersion } },
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
scopeSpans: [{ scope: { name: "graphoria" }, spans: spans.map(renderSpan) }],
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const defaultSend: NonNullable<TracerOptions["send"]> = async (url, init) => {
|
|
192
|
+
const response = await fetch(url, init);
|
|
193
|
+
if (!response.ok) throw new Error(`collector answered HTTP ${response.status}`);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* One async context for every tracer in the process. It holds three scalars
|
|
200
|
+
* rather than the span, so a retained context cannot pin a finished span's
|
|
201
|
+
* attributes in memory.
|
|
202
|
+
*/
|
|
203
|
+
const store = new AsyncLocalStorage<SpanContext>();
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A tracer with no dependencies: W3C ids, OTLP/HTTP JSON, and a bounded export
|
|
207
|
+
* queue. Nothing here reads process state — everything it needs is closed over,
|
|
208
|
+
* so it is unit-testable without booting a server.
|
|
209
|
+
*/
|
|
210
|
+
export const createTracer = ({
|
|
211
|
+
endpoint,
|
|
212
|
+
headers = "",
|
|
213
|
+
serviceName,
|
|
214
|
+
serviceVersion,
|
|
215
|
+
sampleRatio = 1,
|
|
216
|
+
maxBatch = MAX_BATCH,
|
|
217
|
+
maxQueue = MAX_QUEUE,
|
|
218
|
+
exportIntervalMs = EXPORT_INTERVAL_MS,
|
|
219
|
+
send = defaultSend,
|
|
220
|
+
log = logger("tracing"),
|
|
221
|
+
}: TracerOptions): Tracer => {
|
|
222
|
+
const url = `${endpoint.replace(/\/+$/, "")}/v1/traces`;
|
|
223
|
+
const exportHeaders = { "content-type": "application/json", ...parseHeaders(headers) };
|
|
224
|
+
|
|
225
|
+
let queue: FinishedSpan[] = [];
|
|
226
|
+
let dropped = 0;
|
|
227
|
+
|
|
228
|
+
const post = async (batch: FinishedSpan[]) => {
|
|
229
|
+
try {
|
|
230
|
+
await send(url, {
|
|
231
|
+
method: "POST",
|
|
232
|
+
headers: exportHeaders,
|
|
233
|
+
body: JSON.stringify(renderPayload(batch, serviceName, serviceVersion)),
|
|
234
|
+
});
|
|
235
|
+
} catch (error) {
|
|
236
|
+
// Dropped, never retried: an unbounded retry buffer is the failure mode
|
|
237
|
+
// that turns an observability feature into the outage.
|
|
238
|
+
log.warn({ err: error, spans: batch.length }, "span export failed");
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const flush = async () => {
|
|
243
|
+
if (queue.length === 0) return;
|
|
244
|
+
const batch = queue;
|
|
245
|
+
queue = [];
|
|
246
|
+
if (dropped > 0) {
|
|
247
|
+
log.warn({ dropped }, "span queue full, oldest spans dropped");
|
|
248
|
+
dropped = 0;
|
|
249
|
+
}
|
|
250
|
+
await post(batch);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
if (exportIntervalMs > 0) {
|
|
254
|
+
// Unref'd: there is no graceful-shutdown hook to clear it, and a live timer
|
|
255
|
+
// would keep the process alive after the server closes.
|
|
256
|
+
setInterval(() => void flush(), exportIntervalMs).unref();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const enqueue = (span: FinishedSpan) => {
|
|
260
|
+
queue.push(span);
|
|
261
|
+
if (queue.length > maxQueue) {
|
|
262
|
+
dropped += queue.length - maxQueue;
|
|
263
|
+
queue = queue.slice(queue.length - maxQueue);
|
|
264
|
+
}
|
|
265
|
+
if (queue.length >= maxBatch) void flush();
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const shouldSample = (parent: SpanContext | undefined) => {
|
|
269
|
+
if (parent) return parent.sampled;
|
|
270
|
+
if (sampleRatio >= 1) return true;
|
|
271
|
+
if (sampleRatio <= 0) return false;
|
|
272
|
+
return Math.random() < sampleRatio;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const startSpan = (name: string, options: SpanOptions = {}): Span => {
|
|
276
|
+
const parent = options.parent === undefined ? store.getStore() : (options.parent ?? undefined);
|
|
277
|
+
const sampled = shouldSample(parent);
|
|
278
|
+
const context: SpanContext = {
|
|
279
|
+
traceId: parent?.traceId ?? newTraceId(),
|
|
280
|
+
spanId: newSpanId(),
|
|
281
|
+
sampled,
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// Wall clock for the start, monotonic for the length, so a span's duration
|
|
285
|
+
// does not drift with the system clock.
|
|
286
|
+
const startedAtNs = BigInt(Date.now()) * 1_000_000n;
|
|
287
|
+
const startedAt = Bun.nanoseconds();
|
|
288
|
+
const attributes: Attributes = { ...options.attributes };
|
|
289
|
+
let status: SpanStatus = "unset";
|
|
290
|
+
let statusMessage: string | undefined;
|
|
291
|
+
let ended = false;
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
context,
|
|
295
|
+
setAttribute: (key, value) => {
|
|
296
|
+
attributes[key] = value;
|
|
297
|
+
},
|
|
298
|
+
setStatus: (next, message) => {
|
|
299
|
+
status = next;
|
|
300
|
+
statusMessage = message;
|
|
301
|
+
},
|
|
302
|
+
recordError: (error) => {
|
|
303
|
+
status = "error";
|
|
304
|
+
statusMessage = errorMessage(error);
|
|
305
|
+
},
|
|
306
|
+
end: () => {
|
|
307
|
+
if (ended) return;
|
|
308
|
+
ended = true;
|
|
309
|
+
if (!sampled) return;
|
|
310
|
+
enqueue({
|
|
311
|
+
traceId: context.traceId,
|
|
312
|
+
spanId: context.spanId,
|
|
313
|
+
...(parent ? { parentSpanId: parent.spanId } : {}),
|
|
314
|
+
name,
|
|
315
|
+
kind: options.kind ?? "internal",
|
|
316
|
+
startTimeUnixNano: String(startedAtNs),
|
|
317
|
+
endTimeUnixNano: String(startedAtNs + BigInt(Bun.nanoseconds() - startedAt)),
|
|
318
|
+
attributes,
|
|
319
|
+
status,
|
|
320
|
+
...(statusMessage ? { statusMessage } : {}),
|
|
321
|
+
});
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
startSpan,
|
|
328
|
+
withSpan: async (name, options, fn) => {
|
|
329
|
+
const span = startSpan(name, options);
|
|
330
|
+
try {
|
|
331
|
+
return await store.run(span.context, () => fn(span));
|
|
332
|
+
} catch (error) {
|
|
333
|
+
span.recordError(error);
|
|
334
|
+
throw error;
|
|
335
|
+
} finally {
|
|
336
|
+
span.end();
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
active: () => store.getStore(),
|
|
340
|
+
flush,
|
|
341
|
+
};
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Off until boot turns it on, so a handler or an executor used outside a booted
|
|
346
|
+
* server records nothing and never enters the async store.
|
|
347
|
+
*/
|
|
348
|
+
let enabled = false;
|
|
349
|
+
|
|
350
|
+
let settings: Omit<TracerOptions, "serviceVersion"> = {
|
|
351
|
+
endpoint: "http://localhost:4318",
|
|
352
|
+
headers: "",
|
|
353
|
+
serviceName: "graphoria",
|
|
354
|
+
sampleRatio: 1,
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
let override: Tracer | null = null;
|
|
358
|
+
let instance: Tracer | null = null;
|
|
359
|
+
|
|
360
|
+
export const configureTracing = (next: {
|
|
361
|
+
enabled: boolean;
|
|
362
|
+
endpoint: string;
|
|
363
|
+
headers: string;
|
|
364
|
+
serviceName: string;
|
|
365
|
+
sampleRatio: number;
|
|
366
|
+
}): void => {
|
|
367
|
+
enabled = next.enabled;
|
|
368
|
+
settings = {
|
|
369
|
+
endpoint: next.endpoint,
|
|
370
|
+
headers: next.headers,
|
|
371
|
+
serviceName: next.serviceName,
|
|
372
|
+
sampleRatio: next.sampleRatio,
|
|
373
|
+
};
|
|
374
|
+
instance = null;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
/** Test seam: pass `null` to drop the process tracer and its queued spans. */
|
|
378
|
+
export const setTracer = (tracer: Tracer | null): void => {
|
|
379
|
+
override = tracer;
|
|
380
|
+
instance = null;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const tracer = (): Tracer => {
|
|
384
|
+
if (override) return override;
|
|
385
|
+
if (!instance) instance = createTracer({ ...settings, serviceVersion: version });
|
|
386
|
+
return instance;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
export const isTracingEnabled = (): boolean => enabled;
|
|
390
|
+
|
|
391
|
+
export const startSpan = (name: string, options?: SpanOptions): Span | undefined => {
|
|
392
|
+
if (!enabled) return undefined;
|
|
393
|
+
return tracer().startSpan(name, options);
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
export const withSpan = async <T>(
|
|
397
|
+
name: string,
|
|
398
|
+
options: SpanOptions,
|
|
399
|
+
fn: (span: Span | undefined) => T | Promise<T>,
|
|
400
|
+
): Promise<T> => {
|
|
401
|
+
if (!enabled) return fn(undefined);
|
|
402
|
+
return tracer().withSpan(name, options, fn);
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Runs `fn` with `span` active, leaving its lifetime to the caller. A span that
|
|
407
|
+
* is never ended is never exported, which is how a websocket upgrade — a
|
|
408
|
+
* request that is never answered — produces no span at all.
|
|
409
|
+
*/
|
|
410
|
+
export const withActiveSpan = <T>(span: Span | undefined, fn: () => T): T =>
|
|
411
|
+
span ? store.run(span.context, fn) : fn();
|
|
412
|
+
|
|
413
|
+
export const activeSpanContext = (): SpanContext | undefined => {
|
|
414
|
+
if (!enabled) return undefined;
|
|
415
|
+
return tracer().active();
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
export const flushSpans = (): Promise<void> => {
|
|
419
|
+
if (!enabled) return Promise.resolve();
|
|
420
|
+
return tracer().flush();
|
|
421
|
+
};
|