@graphoria/server 0.3.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.
Files changed (203) hide show
  1. package/README.md +28 -20
  2. package/dist/src/ai/mcp/index.d.ts +9 -2
  3. package/dist/src/ai/mcp/index.d.ts.map +1 -1
  4. package/dist/src/analyzeQuery/analyzers/selectionAnalyzer.d.ts.map +1 -1
  5. package/dist/src/analyzeQuery/depthLimit.d.ts +1 -13
  6. package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
  7. package/dist/src/authentication/capabilities.d.ts +18 -0
  8. package/dist/src/authentication/capabilities.d.ts.map +1 -0
  9. package/dist/src/authentication/capabilities.test.d.ts +2 -0
  10. package/dist/src/authentication/capabilities.test.d.ts.map +1 -0
  11. package/dist/src/authentication/jwt.d.ts.map +1 -1
  12. package/dist/src/authentication/paseto.d.ts.map +1 -1
  13. package/dist/src/authentication/secrets.d.ts +9 -0
  14. package/dist/src/authentication/secrets.d.ts.map +1 -0
  15. package/dist/src/authentication/secrets.test.d.ts +2 -0
  16. package/dist/src/authentication/secrets.test.d.ts.map +1 -0
  17. package/dist/src/authentication/tokenRepository.d.ts.map +1 -1
  18. package/dist/src/config/types/db.d.ts +25 -0
  19. package/dist/src/config/types/db.d.ts.map +1 -1
  20. package/dist/src/configuration/getSchemas/index.d.ts +40 -2
  21. package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
  22. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +20 -1
  23. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
  24. package/dist/src/configuration/gql/gqlAuthOperations.d.ts.map +1 -1
  25. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
  26. package/dist/src/configuration/gql/handleGraphQLSubscriptionFactory.d.ts.map +1 -1
  27. package/dist/src/configuration/index.d.ts +30 -1
  28. package/dist/src/configuration/index.d.ts.map +1 -1
  29. package/dist/src/configuration/rest/generateOpenAPI.d.ts.map +1 -1
  30. package/dist/src/configuration/rest/generateOpenAPI.test.d.ts +2 -0
  31. package/dist/src/configuration/rest/generateOpenAPI.test.d.ts.map +1 -0
  32. package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
  33. package/dist/src/console/api.d.ts.map +1 -1
  34. package/dist/src/console/session.d.ts +12 -4
  35. package/dist/src/console/session.d.ts.map +1 -1
  36. package/dist/src/cron/index.d.ts.map +1 -1
  37. package/dist/src/databases/common.d.ts +20 -6
  38. package/dist/src/databases/common.d.ts.map +1 -1
  39. package/dist/src/databases/core/executor.d.ts +4 -6
  40. package/dist/src/databases/core/executor.d.ts.map +1 -1
  41. package/dist/src/databases/core/function-mapping.d.ts +2 -3
  42. package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
  43. package/dist/src/databases/core/procedure-arguments.d.ts +17 -0
  44. package/dist/src/databases/core/procedure-arguments.d.ts.map +1 -0
  45. package/dist/src/databases/core/procedure-arguments.test.d.ts +2 -0
  46. package/dist/src/databases/core/procedure-arguments.test.d.ts.map +1 -0
  47. package/dist/src/databases/core/query-builder.d.ts +5 -0
  48. package/dist/src/databases/core/query-builder.d.ts.map +1 -1
  49. package/dist/src/databases/engines/mssql/connection.d.ts +1 -1
  50. package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
  51. package/dist/src/databases/engines/mssql/getStructure.d.ts +5 -0
  52. package/dist/src/databases/engines/mssql/getStructure.d.ts.map +1 -1
  53. package/dist/src/databases/engines/mssql/query/index.d.ts +2 -2
  54. package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
  55. package/dist/src/databases/engines/mysql/connection.d.ts +1 -1
  56. package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
  57. package/dist/src/databases/engines/mysql/getStructure.d.ts +5 -0
  58. package/dist/src/databases/engines/mysql/getStructure.d.ts.map +1 -1
  59. package/dist/src/databases/engines/mysql/query/index.d.ts +2 -2
  60. package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
  61. package/dist/src/databases/engines/postgresql/connection.d.ts +1 -1
  62. package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
  63. package/dist/src/databases/engines/postgresql/getStructure.d.ts +5 -0
  64. package/dist/src/databases/engines/postgresql/getStructure.d.ts.map +1 -1
  65. package/dist/src/databases/engines/postgresql/query/index.d.ts +2 -2
  66. package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
  67. package/dist/src/databases/high-level-operations.d.ts +5 -0
  68. package/dist/src/databases/high-level-operations.d.ts.map +1 -1
  69. package/dist/src/databases/metadata/structure.d.ts +5 -0
  70. package/dist/src/databases/metadata/structure.d.ts.map +1 -1
  71. package/dist/src/databases/transformers/data-transformers.d.ts +30 -0
  72. package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
  73. package/dist/src/index.d.ts +4 -0
  74. package/dist/src/index.d.ts.map +1 -1
  75. package/dist/src/logging/audit.d.ts +27 -0
  76. package/dist/src/logging/audit.d.ts.map +1 -0
  77. package/dist/src/logging/audit.test.d.ts +2 -0
  78. package/dist/src/logging/audit.test.d.ts.map +1 -0
  79. package/dist/src/logging/slowQuery.d.ts +29 -0
  80. package/dist/src/logging/slowQuery.d.ts.map +1 -0
  81. package/dist/src/logging/slowQuery.test.d.ts +2 -0
  82. package/dist/src/logging/slowQuery.test.d.ts.map +1 -0
  83. package/dist/src/observability/health.d.ts +23 -0
  84. package/dist/src/observability/health.d.ts.map +1 -0
  85. package/dist/src/observability/health.test.d.ts +2 -0
  86. package/dist/src/observability/health.test.d.ts.map +1 -0
  87. package/dist/src/observability/httpMetrics.d.ts +7 -0
  88. package/dist/src/observability/httpMetrics.d.ts.map +1 -0
  89. package/dist/src/observability/httpMetrics.test.d.ts +2 -0
  90. package/dist/src/observability/httpMetrics.test.d.ts.map +1 -0
  91. package/dist/src/observability/httpTracing.d.ts +11 -0
  92. package/dist/src/observability/httpTracing.d.ts.map +1 -0
  93. package/dist/src/observability/httpTracing.test.d.ts +2 -0
  94. package/dist/src/observability/httpTracing.test.d.ts.map +1 -0
  95. package/dist/src/observability/metrics.d.ts +81 -0
  96. package/dist/src/observability/metrics.d.ts.map +1 -0
  97. package/dist/src/observability/metrics.perf.test.d.ts +2 -0
  98. package/dist/src/observability/metrics.perf.test.d.ts.map +1 -0
  99. package/dist/src/observability/metrics.test.d.ts +2 -0
  100. package/dist/src/observability/metrics.test.d.ts.map +1 -0
  101. package/dist/src/observability/metricsRoute.d.ts +19 -0
  102. package/dist/src/observability/metricsRoute.d.ts.map +1 -0
  103. package/dist/src/observability/metricsRoute.test.d.ts +2 -0
  104. package/dist/src/observability/metricsRoute.test.d.ts.map +1 -0
  105. package/dist/src/observability/tracing.d.ts +92 -0
  106. package/dist/src/observability/tracing.d.ts.map +1 -0
  107. package/dist/src/observability/tracing.perf.test.d.ts +2 -0
  108. package/dist/src/observability/tracing.perf.test.d.ts.map +1 -0
  109. package/dist/src/observability/tracing.test.d.ts +2 -0
  110. package/dist/src/observability/tracing.test.d.ts.map +1 -0
  111. package/dist/src/queues/kafka.d.ts +6 -0
  112. package/dist/src/queues/kafka.d.ts.map +1 -1
  113. package/dist/src/queues/kafka.test.d.ts +2 -0
  114. package/dist/src/queues/kafka.test.d.ts.map +1 -0
  115. package/dist/src/queues/rabbitmq.d.ts.map +1 -1
  116. package/dist/src/remoteREST/proxy.d.ts.map +1 -1
  117. package/dist/src/remoteSchemas/proxy.d.ts.map +1 -1
  118. package/dist/src/singletons/cache/redisClient.d.ts +2 -2
  119. package/dist/src/singletons/cache/redisClient.d.ts.map +1 -1
  120. package/dist/src/singletons/databases.d.ts +1 -0
  121. package/dist/src/singletons/databases.d.ts.map +1 -1
  122. package/dist/src/singletons/env.d.ts +37 -4
  123. package/dist/src/singletons/env.d.ts.map +1 -1
  124. package/dist/src/singletons/queues.d.ts +1 -0
  125. package/dist/src/singletons/queues.d.ts.map +1 -1
  126. package/dist/src/subscriptions/strategies/database.d.ts.map +1 -1
  127. package/dist/src/subscriptions/utils/polling.d.ts +1 -0
  128. package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
  129. package/dist/src/subscriptions/utils/polling.test.d.ts +2 -0
  130. package/dist/src/subscriptions/utils/polling.test.d.ts.map +1 -0
  131. package/dist/src/types/env.d.ts +113 -12
  132. package/dist/src/types/env.d.ts.map +1 -1
  133. package/dist/src/types/zod/configuration.d.ts +25 -0
  134. package/dist/src/types/zod/configuration.d.ts.map +1 -1
  135. package/dist/src/types/zod/db.d.ts +85 -0
  136. package/dist/src/types/zod/db.d.ts.map +1 -1
  137. package/dist/src/utils/rateLimit.d.ts.map +1 -1
  138. package/dist/src/utils/redis.d.ts +17 -0
  139. package/dist/src/utils/redis.d.ts.map +1 -0
  140. package/dist/src/utils/redis.test.d.ts +2 -0
  141. package/dist/src/utils/redis.test.d.ts.map +1 -0
  142. package/dist/src/utils/responses.d.ts +6 -0
  143. package/dist/src/utils/responses.d.ts.map +1 -1
  144. package/dist/src/utils/sessionVariables.d.ts +2 -0
  145. package/dist/src/utils/sessionVariables.d.ts.map +1 -1
  146. package/dist/tsconfig.tsbuildinfo +1 -1
  147. package/package.json +8 -5
  148. package/playgrounds/console/index.html +9 -9
  149. package/playgrounds/graphiql/index.html +549 -686
  150. package/playgrounds/scalar/index.html +1 -1
  151. package/src/ai/mcp/index.ts +19 -14
  152. package/src/analyzeQuery/analyzers/selectionAnalyzer.ts +2 -1
  153. package/src/analyzeQuery/depthLimit.ts +15 -5
  154. package/src/authentication/capabilities.ts +56 -0
  155. package/src/authentication/index.ts +3 -3
  156. package/src/authentication/jwt.ts +30 -25
  157. package/src/authentication/paseto.ts +36 -26
  158. package/src/authentication/secrets.ts +30 -0
  159. package/src/authentication/tokenRepository.ts +2 -3
  160. package/src/config/types/db.ts +16 -2
  161. package/src/configuration/getSchemas/mergeEntities/index.ts +0 -26
  162. package/src/configuration/gql/gqlAuthOperations.ts +26 -0
  163. package/src/configuration/gql/handleGraphQLRequestFactory.ts +241 -148
  164. package/src/configuration/gql/handleGraphQLSubscriptionFactory.ts +9 -0
  165. package/src/configuration/rest/generateOpenAPI.ts +61 -11
  166. package/src/configuration/rest/handleRESTRequestFactory.ts +26 -0
  167. package/src/console/api.ts +64 -24
  168. package/src/console/session.ts +30 -26
  169. package/src/cron/index.ts +59 -36
  170. package/src/databases/common.ts +109 -10
  171. package/src/databases/core/executor.ts +97 -20
  172. package/src/databases/core/function-mapping.ts +1 -2
  173. package/src/databases/core/procedure-arguments.ts +25 -0
  174. package/src/databases/engines/mssql/connection.ts +0 -3
  175. package/src/databases/engines/mssql/query/index.ts +4 -1
  176. package/src/databases/engines/mysql/connection.ts +10 -7
  177. package/src/databases/engines/mysql/query/index.ts +5 -2
  178. package/src/databases/engines/postgresql/connection.ts +10 -7
  179. package/src/databases/engines/postgresql/query/index.ts +5 -2
  180. package/src/databases/high-level-operations.ts +44 -2
  181. package/src/index.ts +200 -76
  182. package/src/logging/audit.ts +86 -0
  183. package/src/logging/slowQuery.ts +59 -0
  184. package/src/observability/health.ts +67 -0
  185. package/src/observability/httpMetrics.ts +37 -0
  186. package/src/observability/httpTracing.ts +48 -0
  187. package/src/observability/metrics.ts +243 -0
  188. package/src/observability/metricsRoute.ts +33 -0
  189. package/src/observability/tracing.ts +421 -0
  190. package/src/queues/kafka.ts +45 -2
  191. package/src/queues/rabbitmq.ts +37 -1
  192. package/src/remoteREST/proxy.ts +17 -0
  193. package/src/remoteSchemas/proxy.ts +17 -0
  194. package/src/singletons/cache/redisClient.ts +4 -5
  195. package/src/singletons/databases.ts +5 -0
  196. package/src/singletons/queues.ts +9 -3
  197. package/src/subscriptions/strategies/database.ts +2 -0
  198. package/src/subscriptions/utils/polling.ts +80 -21
  199. package/src/types/env.ts +53 -8
  200. package/src/utils/rateLimit.ts +6 -1
  201. package/src/utils/redis.ts +71 -0
  202. package/src/utils/responses.ts +12 -0
  203. package/src/utils/sessionVariables.ts +2 -0
@@ -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
+ };
@@ -6,6 +6,8 @@ import type { KafkaConfig } from "../types/zod/queue";
6
6
 
7
7
  import { queryEventEmitter } from "../configuration/gql/handleGraphQLSubscriptionFactory";
8
8
  import { logger } from "../logging";
9
+ import { incMetric } from "../observability/metrics";
10
+ import { startSpan } from "../observability/tracing";
9
11
 
10
12
  // ============================================================================
11
13
  // Reconnection Configuration
@@ -58,9 +60,19 @@ export const startConsumer = async (
58
60
 
59
61
  await consumer.run({
60
62
  eachMessage: async (payload) => {
63
+ const record = (outcome: "success" | "error") =>
64
+ incMetric("graphoria_queue_messages_consumed_total", {
65
+ broker: "kafka",
66
+ queue: connectionName,
67
+ consumer: name,
68
+ outcome,
69
+ });
70
+
61
71
  try {
62
72
  sendMessage(connectionName, name, payload);
73
+ record("success");
63
74
  } catch (error) {
75
+ record("error");
64
76
  log.error({ err: error }, "message processing failed");
65
77
  // Note: Kafka doesn't have individual message nack like RabbitMQ
66
78
  // Error handling would be done through retry mechanisms or dead letter topics
@@ -107,7 +119,15 @@ process.on("warning", (warning) => {
107
119
  );
108
120
  });
109
121
 
110
- const createKafkaConnectionManager = (queueConfig: KafkaConfig): KafkaConnectionManager => {
122
+ export type KafkaDependencies = {
123
+ /** Test seam: the manager builds its client through this. */
124
+ createKafka?: (config: ConstructorParameters<typeof Kafka>[0]) => Kafka;
125
+ };
126
+
127
+ export const createKafkaConnectionManager = (
128
+ queueConfig: KafkaConfig,
129
+ { createKafka = (config) => new Kafka(config) }: KafkaDependencies = {},
130
+ ): KafkaConnectionManager => {
111
131
  if (queueConfig.type !== "kafka") {
112
132
  throw new Error("Invalid queue type for Kafka connection manager");
113
133
  }
@@ -158,7 +178,7 @@ const createKafkaConnectionManager = (queueConfig: KafkaConfig): KafkaConnection
158
178
  const connConfig = getConnectionConfig();
159
179
 
160
180
  // Create Kafka client with built-in retry
161
- const kafka = new Kafka({
181
+ const kafka = createKafka({
162
182
  clientId: connConfig.clientId ?? `datagraph-${queueConfig.name}`,
163
183
  brokers: connConfig.brokers,
164
184
  ssl: connConfig.ssl,
@@ -205,8 +225,29 @@ const createKafkaConnectionManager = (queueConfig: KafkaConfig): KafkaConnection
205
225
  name: p.name,
206
226
  topic: exchange.name,
207
227
  send: async (message: string | object, key?: string) => {
228
+ const publisher = `${queueConfig.name}_${p.name}`;
229
+ // No message body on the span — it is caller data.
230
+ const span = startSpan("queue.publish", {
231
+ kind: "producer",
232
+ attributes: {
233
+ "messaging.system": "kafka",
234
+ "messaging.destination.name": exchange.name,
235
+ "graphoria.queue.publisher": publisher,
236
+ },
237
+ });
238
+ const record = (outcome: "success" | "error") => {
239
+ incMetric("graphoria_queue_messages_published_total", {
240
+ broker: "kafka",
241
+ publisher,
242
+ outcome,
243
+ });
244
+ if (outcome === "error") span?.setStatus("error");
245
+ span?.end();
246
+ };
247
+
208
248
  if (!state.producer) {
209
249
  log.error("cannot send: producer not available");
250
+ record("error");
210
251
  return false;
211
252
  }
212
253
  try {
@@ -220,9 +261,11 @@ const createKafkaConnectionManager = (queueConfig: KafkaConfig): KafkaConnection
220
261
  },
221
262
  ],
222
263
  });
264
+ record("success");
223
265
  return true;
224
266
  } catch (error) {
225
267
  log.error({ err: error, topic: exchange.name }, "failed to send message");
268
+ record("error");
226
269
  return false;
227
270
  }
228
271
  },
@@ -7,6 +7,8 @@ import type { RabbitMQConfig } from "../types/zod/queue";
7
7
  import { queryEventEmitter } from "../configuration/gql/handleGraphQLSubscriptionFactory";
8
8
  import { InvalidationHelper } from "../singletons/cache/registry";
9
9
  import { logger } from "../logging";
10
+ import { incMetric } from "../observability/metrics";
11
+ import { startSpan } from "../observability/tracing";
10
12
 
11
13
  // ============================================================================
12
14
  // Reconnection Configuration
@@ -41,6 +43,14 @@ export const startConsumer = async (
41
43
  ) => {
42
44
  const log = logger("rabbitmq").child({ queue: connectionName, consumer: name });
43
45
 
46
+ const record = (outcome: "success" | "error") =>
47
+ incMetric("graphoria_queue_messages_consumed_total", {
48
+ broker: "rabbitmq",
49
+ queue: connectionName,
50
+ consumer: name,
51
+ outcome,
52
+ });
53
+
44
54
  channel.consume(queueName, async (msg) => {
45
55
  if (msg !== null) {
46
56
  try {
@@ -58,10 +68,12 @@ export const startConsumer = async (
58
68
  }
59
69
 
60
70
  channel.ack(msg);
71
+ record("success");
61
72
  } catch (error) {
62
73
  log.error({ err: error }, "message processing failed");
63
74
 
64
75
  channel.nack(msg);
76
+ record("error");
65
77
  }
66
78
  }
67
79
  });
@@ -162,16 +174,40 @@ export const createRabbitMQConnectionManager = (
162
174
  routingKey: p.routingKey,
163
175
  exchangeName: exchange.name,
164
176
  send: (message: string | object) => {
177
+ const publisher = `${queueConfig.name}_${p.name}`;
178
+ // No message body on the span — it is caller data.
179
+ const span = startSpan("queue.publish", {
180
+ kind: "producer",
181
+ attributes: {
182
+ "messaging.system": "rabbitmq",
183
+ "messaging.destination.name": exchange.name,
184
+ "graphoria.queue.publisher": publisher,
185
+ },
186
+ });
187
+ const record = (outcome: "success" | "error") => {
188
+ incMetric("graphoria_queue_messages_published_total", {
189
+ broker: "rabbitmq",
190
+ publisher,
191
+ outcome,
192
+ });
193
+ if (outcome === "error") span?.setStatus("error");
194
+ span?.end();
195
+ };
196
+
165
197
  if (!state.channel) {
166
198
  log.error("cannot send: channel not available");
199
+ record("error");
167
200
  return false;
168
201
  }
169
- return state.channel.publish(
202
+
203
+ const sent = state.channel.publish(
170
204
  exchange.name,
171
205
  p.routingKey,
172
206
  Buffer.from(typeof message === "string" ? message : JSON.stringify(message)),
173
207
  p.options,
174
208
  );
209
+ record(sent ? "success" : "error");
210
+ return sent;
175
211
  },
176
212
  };
177
213
  }
@@ -1,5 +1,7 @@
1
1
  import type { RemoteRESTResolved, RemoteRESTRoute } from "./types";
2
2
 
3
+ import { formatTraceparent, startSpan } from "../observability/tracing";
4
+
3
5
  /**
4
6
  * Build headers for the remote request by merging:
5
7
  * 1. Static headers from configuration
@@ -62,6 +64,16 @@ export const proxyRemoteRESTRequest = async (
62
64
  const controller = new AbortController();
63
65
  const timeoutId = setTimeout(() => controller.abort(), resolved.config.timeout ?? 10000);
64
66
 
67
+ // The host only: a remote URL's path and query string carry caller data.
68
+ const span = startSpan("graphoria.remote_rest", {
69
+ kind: "client",
70
+ attributes: {
71
+ "http.request.method": route.method.toUpperCase(),
72
+ "server.address": new URL(targetUrl).host,
73
+ },
74
+ });
75
+ if (span) headers["traceparent"] = formatTraceparent(span.context);
76
+
65
77
  try {
66
78
  const fetchInit: RequestInit = {
67
79
  method: route.method.toUpperCase(),
@@ -77,6 +89,7 @@ export const proxyRemoteRESTRequest = async (
77
89
  }
78
90
 
79
91
  const response = await fetch(targetUrl, fetchInit);
92
+ span?.setAttribute("http.response.status_code", response.status);
80
93
 
81
94
  // Return the response with the same status and body
82
95
  return new Response(response.body, {
@@ -86,7 +99,11 @@ export const proxyRemoteRESTRequest = async (
86
99
  "content-type": response.headers.get("content-type") ?? "application/json",
87
100
  },
88
101
  });
102
+ } catch (error) {
103
+ span?.recordError(error);
104
+ throw error;
89
105
  } finally {
106
+ span?.end();
90
107
  clearTimeout(timeoutId);
91
108
  }
92
109
  };
@@ -1,6 +1,8 @@
1
1
  import type { SelectionAnalysis } from "../analyzeQuery/types";
2
2
  import type { RemoteSchemaResolved } from "./types";
3
3
 
4
+ import { formatTraceparent, startSpan } from "../observability/tracing";
5
+
4
6
  /**
5
7
  * Build the sub-query string for a remote field by stripping the prefix from
6
8
  * the field name and type references.
@@ -110,6 +112,16 @@ export const proxyRemoteField = async (
110
112
  const controller = new AbortController();
111
113
  const timeoutId = setTimeout(() => controller.abort(), remoteSchema.config.timeout ?? 10000);
112
114
 
115
+ // The host only: a remote URL's path and query string carry caller data.
116
+ const span = startSpan("graphoria.remote_schema", {
117
+ kind: "client",
118
+ attributes: {
119
+ "http.request.method": "POST",
120
+ "server.address": new URL(remoteSchema.config.url).host,
121
+ },
122
+ });
123
+ if (span) headers["traceparent"] = formatTraceparent(span.context);
124
+
113
125
  try {
114
126
  const response = await fetch(remoteSchema.config.url, {
115
127
  method: "POST",
@@ -120,6 +132,7 @@ export const proxyRemoteField = async (
120
132
  }),
121
133
  signal: controller.signal,
122
134
  });
135
+ span?.setAttribute("http.response.status_code", response.status);
123
136
 
124
137
  if (!response.ok) {
125
138
  throw new Error(
@@ -140,7 +153,11 @@ export const proxyRemoteField = async (
140
153
 
141
154
  // Extract the result for the original field name
142
155
  return json.data?.[originalFieldName] ?? null;
156
+ } catch (error) {
157
+ span?.recordError(error);
158
+ throw error;
143
159
  } finally {
160
+ span?.end();
144
161
  clearTimeout(timeoutId);
145
162
  }
146
163
  };
@@ -1,12 +1,11 @@
1
- import { RedisClient as ValkeyClient } from "bun";
2
-
3
1
  import { env } from "../env";
2
+ import { createRedisClient } from "../../utils/redis";
4
3
 
5
- let client: InstanceType<typeof ValkeyClient> | null = null;
4
+ let client: ReturnType<typeof createRedisClient> | null = null;
6
5
 
7
- export const getCacheRedisClient = (): InstanceType<typeof ValkeyClient> => {
6
+ export const getCacheRedisClient = (): ReturnType<typeof createRedisClient> => {
8
7
  if (!client) {
9
- client = new ValkeyClient(env.cache.redisUrl);
8
+ client = createRedisClient(env.cache.redisUrl);
10
9
  }
11
10
  return client;
12
11
  };