@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.
Files changed (150) hide show
  1. package/README.md +4 -0
  2. package/dist/src/analyzeQuery/depthLimit.d.ts +1 -13
  3. package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
  4. package/dist/src/authentication/capabilities.d.ts +1 -1
  5. package/dist/src/authentication/capabilities.d.ts.map +1 -1
  6. package/dist/src/authentication/tokenRepository.d.ts.map +1 -1
  7. package/dist/src/config/types/db.d.ts +25 -0
  8. package/dist/src/config/types/db.d.ts.map +1 -1
  9. package/dist/src/configuration/getSchemas/index.d.ts +40 -0
  10. package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
  11. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +20 -0
  12. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
  13. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
  14. package/dist/src/configuration/index.d.ts +30 -0
  15. package/dist/src/configuration/index.d.ts.map +1 -1
  16. package/dist/src/console/api.d.ts.map +1 -1
  17. package/dist/src/cron/index.d.ts.map +1 -1
  18. package/dist/src/databases/common.d.ts +20 -6
  19. package/dist/src/databases/common.d.ts.map +1 -1
  20. package/dist/src/databases/core/executor.d.ts +4 -6
  21. package/dist/src/databases/core/executor.d.ts.map +1 -1
  22. package/dist/src/databases/core/function-mapping.d.ts +1 -2
  23. package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
  24. package/dist/src/databases/core/query-builder.d.ts +5 -0
  25. package/dist/src/databases/core/query-builder.d.ts.map +1 -1
  26. package/dist/src/databases/engines/mssql/getStructure.d.ts +5 -0
  27. package/dist/src/databases/engines/mssql/getStructure.d.ts.map +1 -1
  28. package/dist/src/databases/engines/mssql/query/index.d.ts +2 -2
  29. package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
  30. package/dist/src/databases/engines/mysql/getStructure.d.ts +5 -0
  31. package/dist/src/databases/engines/mysql/getStructure.d.ts.map +1 -1
  32. package/dist/src/databases/engines/mysql/query/index.d.ts +2 -2
  33. package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
  34. package/dist/src/databases/engines/postgresql/getStructure.d.ts +5 -0
  35. package/dist/src/databases/engines/postgresql/getStructure.d.ts.map +1 -1
  36. package/dist/src/databases/engines/postgresql/query/index.d.ts +2 -2
  37. package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
  38. package/dist/src/databases/high-level-operations.d.ts +5 -0
  39. package/dist/src/databases/high-level-operations.d.ts.map +1 -1
  40. package/dist/src/databases/metadata/structure.d.ts +5 -0
  41. package/dist/src/databases/metadata/structure.d.ts.map +1 -1
  42. package/dist/src/databases/transformers/data-transformers.d.ts +30 -0
  43. package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
  44. package/dist/src/index.d.ts +4 -0
  45. package/dist/src/index.d.ts.map +1 -1
  46. package/dist/src/logging/slowQuery.d.ts +29 -0
  47. package/dist/src/logging/slowQuery.d.ts.map +1 -0
  48. package/dist/src/logging/slowQuery.test.d.ts +2 -0
  49. package/dist/src/logging/slowQuery.test.d.ts.map +1 -0
  50. package/dist/src/observability/health.d.ts +23 -0
  51. package/dist/src/observability/health.d.ts.map +1 -0
  52. package/dist/src/observability/health.test.d.ts +2 -0
  53. package/dist/src/observability/health.test.d.ts.map +1 -0
  54. package/dist/src/observability/httpMetrics.d.ts +7 -0
  55. package/dist/src/observability/httpMetrics.d.ts.map +1 -0
  56. package/dist/src/observability/httpMetrics.test.d.ts +2 -0
  57. package/dist/src/observability/httpMetrics.test.d.ts.map +1 -0
  58. package/dist/src/observability/httpTracing.d.ts +11 -0
  59. package/dist/src/observability/httpTracing.d.ts.map +1 -0
  60. package/dist/src/observability/httpTracing.test.d.ts +2 -0
  61. package/dist/src/observability/httpTracing.test.d.ts.map +1 -0
  62. package/dist/src/observability/metrics.d.ts +81 -0
  63. package/dist/src/observability/metrics.d.ts.map +1 -0
  64. package/dist/src/observability/metrics.perf.test.d.ts +2 -0
  65. package/dist/src/observability/metrics.perf.test.d.ts.map +1 -0
  66. package/dist/src/observability/metrics.test.d.ts +2 -0
  67. package/dist/src/observability/metrics.test.d.ts.map +1 -0
  68. package/dist/src/observability/metricsRoute.d.ts +19 -0
  69. package/dist/src/observability/metricsRoute.d.ts.map +1 -0
  70. package/dist/src/observability/metricsRoute.test.d.ts +2 -0
  71. package/dist/src/observability/metricsRoute.test.d.ts.map +1 -0
  72. package/dist/src/observability/tracing.d.ts +92 -0
  73. package/dist/src/observability/tracing.d.ts.map +1 -0
  74. package/dist/src/observability/tracing.perf.test.d.ts +2 -0
  75. package/dist/src/observability/tracing.perf.test.d.ts.map +1 -0
  76. package/dist/src/observability/tracing.test.d.ts +2 -0
  77. package/dist/src/observability/tracing.test.d.ts.map +1 -0
  78. package/dist/src/queues/kafka.d.ts +6 -0
  79. package/dist/src/queues/kafka.d.ts.map +1 -1
  80. package/dist/src/queues/kafka.test.d.ts +2 -0
  81. package/dist/src/queues/kafka.test.d.ts.map +1 -0
  82. package/dist/src/queues/rabbitmq.d.ts.map +1 -1
  83. package/dist/src/remoteREST/proxy.d.ts.map +1 -1
  84. package/dist/src/remoteSchemas/proxy.d.ts.map +1 -1
  85. package/dist/src/singletons/cache/redisClient.d.ts +2 -2
  86. package/dist/src/singletons/cache/redisClient.d.ts.map +1 -1
  87. package/dist/src/singletons/databases.d.ts +1 -0
  88. package/dist/src/singletons/databases.d.ts.map +1 -1
  89. package/dist/src/singletons/env.d.ts +29 -0
  90. package/dist/src/singletons/env.d.ts.map +1 -1
  91. package/dist/src/singletons/queues.d.ts +1 -0
  92. package/dist/src/singletons/queues.d.ts.map +1 -1
  93. package/dist/src/subscriptions/strategies/database.d.ts.map +1 -1
  94. package/dist/src/subscriptions/utils/polling.d.ts +1 -0
  95. package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
  96. package/dist/src/subscriptions/utils/polling.test.d.ts +2 -0
  97. package/dist/src/subscriptions/utils/polling.test.d.ts.map +1 -0
  98. package/dist/src/types/env.d.ts +89 -0
  99. package/dist/src/types/env.d.ts.map +1 -1
  100. package/dist/src/types/zod/configuration.d.ts +25 -0
  101. package/dist/src/types/zod/configuration.d.ts.map +1 -1
  102. package/dist/src/types/zod/db.d.ts +85 -0
  103. package/dist/src/types/zod/db.d.ts.map +1 -1
  104. package/dist/src/utils/rateLimit.d.ts.map +1 -1
  105. package/dist/src/utils/redis.d.ts +17 -0
  106. package/dist/src/utils/redis.d.ts.map +1 -0
  107. package/dist/src/utils/redis.test.d.ts +2 -0
  108. package/dist/src/utils/redis.test.d.ts.map +1 -0
  109. package/dist/src/utils/responses.d.ts +3 -0
  110. package/dist/src/utils/responses.d.ts.map +1 -1
  111. package/dist/tsconfig.tsbuildinfo +1 -1
  112. package/package.json +4 -1
  113. package/playgrounds/console/index.html +8 -8
  114. package/playgrounds/graphiql/index.html +114 -114
  115. package/playgrounds/scalar/index.html +1 -1
  116. package/src/analyzeQuery/depthLimit.ts +15 -5
  117. package/src/authentication/capabilities.ts +3 -1
  118. package/src/authentication/tokenRepository.ts +2 -3
  119. package/src/config/types/db.ts +16 -2
  120. package/src/configuration/gql/handleGraphQLRequestFactory.ts +233 -152
  121. package/src/console/api.ts +1 -6
  122. package/src/cron/index.ts +59 -36
  123. package/src/databases/common.ts +109 -10
  124. package/src/databases/core/executor.ts +96 -18
  125. package/src/databases/core/function-mapping.ts +1 -1
  126. package/src/databases/engines/mssql/query/index.ts +4 -1
  127. package/src/databases/engines/mysql/query/index.ts +5 -2
  128. package/src/databases/engines/postgresql/query/index.ts +5 -2
  129. package/src/databases/high-level-operations.ts +44 -2
  130. package/src/index.ts +147 -62
  131. package/src/logging/slowQuery.ts +59 -0
  132. package/src/observability/health.ts +67 -0
  133. package/src/observability/httpMetrics.ts +37 -0
  134. package/src/observability/httpTracing.ts +48 -0
  135. package/src/observability/metrics.ts +243 -0
  136. package/src/observability/metricsRoute.ts +33 -0
  137. package/src/observability/tracing.ts +421 -0
  138. package/src/queues/kafka.ts +45 -2
  139. package/src/queues/rabbitmq.ts +37 -1
  140. package/src/remoteREST/proxy.ts +17 -0
  141. package/src/remoteSchemas/proxy.ts +17 -0
  142. package/src/singletons/cache/redisClient.ts +4 -5
  143. package/src/singletons/databases.ts +5 -0
  144. package/src/singletons/queues.ts +9 -3
  145. package/src/subscriptions/strategies/database.ts +2 -0
  146. package/src/subscriptions/utils/polling.ts +80 -21
  147. package/src/types/env.ts +27 -0
  148. package/src/utils/rateLimit.ts +6 -1
  149. package/src/utils/redis.ts +71 -0
  150. package/src/utils/responses.ts +6 -0
@@ -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
  };
@@ -50,6 +50,11 @@ export const instantiateDatabasesConnections = async (databases: Database[]) =>
50
50
  return { databasesConnections, repositoryMap };
51
51
  };
52
52
 
53
+ export const pingConnection = (connection: SQL | ConnectionPool, type: string) =>
54
+ type === "mssql"
55
+ ? (connection as ConnectionPool).query("SELECT 1")
56
+ : (connection as SQL).unsafe("SELECT 1");
57
+
53
58
  /**
54
59
  * Close every open database connection and clear the singleton maps. Bun's `SQL`
55
60
  * and mssql's `ConnectionPool` both expose `close()`. Used by
@@ -6,7 +6,11 @@ import { startKafkaConnections } from "../queues/kafka";
6
6
  import { startRabbitMQConnections } from "../queues/rabbitmq";
7
7
  import { logger } from "../logging";
8
8
 
9
- export type QueueConnectionStatus = { type: "rabbitmq" | "kafka"; connected: boolean };
9
+ export type QueueConnectionStatus = {
10
+ type: "rabbitmq" | "kafka";
11
+ name: string;
12
+ connected: boolean;
13
+ };
10
14
 
11
15
  export type QueueManager = {
12
16
  publisherMap: () => Record<string, RabbitMQPublisher | KafkaPublisher>;
@@ -40,8 +44,9 @@ export const instantiateQueues = async (queues: QueueConfig[]) => {
40
44
  publisherMap: rabbitMQManager.publisherMap,
41
45
  sendMessage: rabbitMQManager.sendMessage,
42
46
  connections: () =>
43
- rabbitMQManager.managers.map((manager) => ({
47
+ rabbitMQManager.managers.map((manager, index) => ({
44
48
  type: "rabbitmq" as const,
49
+ name: rabbitMQQueues[index]!.name,
45
50
  connected: manager.isConnected(),
46
51
  })),
47
52
  });
@@ -55,8 +60,9 @@ export const instantiateQueues = async (queues: QueueConfig[]) => {
55
60
  publisherMap: kafkaManager.publisherMap,
56
61
  sendMessage: kafkaManager.sendMessage,
57
62
  connections: () =>
58
- kafkaManager.managers.map((manager) => ({
63
+ kafkaManager.managers.map((manager, index) => ({
59
64
  type: "kafka" as const,
65
+ name: kafkaQueues[index]!.name,
60
66
  connected: manager.isConnected(),
61
67
  })),
62
68
  cleanup: kafkaManager.cleanup,
@@ -46,6 +46,7 @@ export const createDatabaseSubscriptionStrategy = (): SubscriptionStrategy => ({
46
46
  variables,
47
47
  schemaEntity,
48
48
  eventEmitter,
49
+ session,
49
50
  } = context;
50
51
 
51
52
  const subscriptionKey = this.getSubscriptionKey(context);
@@ -69,6 +70,7 @@ export const createDatabaseSubscriptionStrategy = (): SubscriptionStrategy => ({
69
70
  subscriptionKey,
70
71
  eventEmitter,
71
72
  pollIntervalMs: 1000,
73
+ role: session.role,
72
74
  });
73
75
 
74
76
  // Store poll reference for cleanup by event emitter
@@ -1,10 +1,12 @@
1
1
  import type { AnalysisResult, VariableDefinition } from "../../analyzeQuery/types";
2
2
  import type { GetSchemaReturn } from "../../configuration/getSchemas";
3
+ import type { QuerySource } from "../../logging/slowQuery";
3
4
  import type { Database } from "../../types/configuration";
4
5
  import type { DatabasePoller, QueryEventEmitter } from "../types";
5
6
 
6
7
  import { executeQuery, executeQueryJSON, generateSQL } from "../../databases";
7
8
  import { logger } from "../../logging";
9
+ import { withSpan } from "../../observability/tracing";
8
10
  import { env } from "../../singletons/env";
9
11
 
10
12
  export interface DatabasePollerConfig {
@@ -15,6 +17,7 @@ export interface DatabasePollerConfig {
15
17
  subscriptionKey: string;
16
18
  eventEmitter: QueryEventEmitter;
17
19
  pollIntervalMs?: number;
20
+ role?: string | undefined;
18
21
  }
19
22
 
20
23
  /**
@@ -32,12 +35,15 @@ const getResultHash = async (
32
35
  db: Database,
33
36
  variableDefinitions: VariableDefinition[],
34
37
  variables: Record<string, unknown>,
38
+ source: QuerySource,
35
39
  ): Promise<string> => {
36
40
  const hashResult = await executeQuery<{ ResultHash: string }>(
37
41
  queryHash,
38
42
  db,
39
43
  variableDefinitions,
40
44
  variables,
45
+ undefined,
46
+ source,
41
47
  );
42
48
  return Buffer.from(hashResult[0].ResultHash)?.toString("hex");
43
49
  };
@@ -56,6 +62,7 @@ export const createDatabasePoller = async (
56
62
  subscriptionKey,
57
63
  eventEmitter,
58
64
  pollIntervalMs = 1000,
65
+ role,
59
66
  } = config;
60
67
 
61
68
  const firstFieldName = getFirstFieldName(analysis);
@@ -67,35 +74,87 @@ export const createDatabasePoller = async (
67
74
  const [[, queryData]] = generateSQL(schemaEntity, analysis, variables, false, pageLimits);
68
75
  const [[, queryHash]] = generateSQL(schemaEntity, analysis, variables, true, pageLimits);
69
76
 
70
- // Get initial hash and send initial data
71
- let previousHash = await getResultHash(queryHash, db, variableDefinitions, variables);
77
+ const operation = analysis.operations[0];
78
+ const source: QuerySource = {
79
+ operation: {
80
+ type: operation.operation,
81
+ name: operation.name,
82
+ fields: operation.fields.map((field) => field.name),
83
+ },
84
+ role,
85
+ };
86
+
87
+ // A poll is not inside a request, so each one roots its own trace. The span is
88
+ // entered, which is what puts the statements it runs underneath it. An empty
89
+ // poll still gets a span — an empty poll that is slow is worth seeing.
90
+ const pollSpan = <T>(run: () => Promise<T>) =>
91
+ withSpan(
92
+ "subscription.poll",
93
+ {
94
+ parent: null,
95
+ attributes: {
96
+ "graphql.operation.name": source.operation.name ?? undefined,
97
+ "graphql.operation.type": source.operation.type,
98
+ "graphoria.role": source.role,
99
+ },
100
+ },
101
+ run,
102
+ );
72
103
 
73
- eventEmitter.sendDataUpdate(subscriptionKey, {
74
- data: await executeQueryJSON(queryData, db, variableDefinitions, variables),
104
+ // Get initial hash and send initial data
105
+ let previousHash = "";
106
+
107
+ await pollSpan(async () => {
108
+ previousHash = await getResultHash(queryHash, db, variableDefinitions, variables, source);
109
+
110
+ eventEmitter.sendDataUpdate(subscriptionKey, {
111
+ data: await executeQueryJSON(
112
+ queryData,
113
+ db,
114
+ variableDefinitions,
115
+ variables,
116
+ undefined,
117
+ source,
118
+ ),
119
+ });
75
120
  });
76
121
 
77
122
  // Poll function that checks for changes
78
123
  const log = logger("polling").child({ subscription: subscriptionKey });
79
- const poll = async () => {
80
- try {
81
- const currentHash = await getResultHash(queryHash, db, variableDefinitions, variables);
82
-
83
- if (currentHash !== previousHash) {
84
- log.info(
85
- { changed: !!previousHash, operation: analysis.operations[0].name },
86
- previousHash ? "data changed" : "initial fetch",
124
+ const poll = () =>
125
+ pollSpan(async () => {
126
+ try {
127
+ const currentHash = await getResultHash(
128
+ queryHash,
129
+ db,
130
+ variableDefinitions,
131
+ variables,
132
+ source,
87
133
  );
88
134
 
89
- previousHash = currentHash;
90
-
91
- eventEmitter.sendDataUpdate(subscriptionKey, {
92
- data: await executeQueryJSON(queryData, db, variableDefinitions, variables),
93
- });
135
+ if (currentHash !== previousHash) {
136
+ log.info(
137
+ { changed: !!previousHash, operation: analysis.operations[0].name },
138
+ previousHash ? "data changed" : "initial fetch",
139
+ );
140
+
141
+ previousHash = currentHash;
142
+
143
+ eventEmitter.sendDataUpdate(subscriptionKey, {
144
+ data: await executeQueryJSON(
145
+ queryData,
146
+ db,
147
+ variableDefinitions,
148
+ variables,
149
+ undefined,
150
+ source,
151
+ ),
152
+ });
153
+ }
154
+ } catch (error) {
155
+ log.error({ err: error }, "polling failed");
94
156
  }
95
- } catch (error) {
96
- log.error({ err: error }, "polling failed");
97
- }
98
- };
157
+ });
99
158
 
100
159
  // Poller control
101
160
  let stopped = false;
package/src/types/env.ts CHANGED
@@ -52,6 +52,19 @@ export const EnvZod = z
52
52
  DEFAULT_PAGE_SIZE: z.coerce.number().int().min(0).default(100),
53
53
  MAX_PAGE_SIZE: z.coerce.number().int().min(0).default(1000),
54
54
  QUERY_TIMEOUT_MS: z.coerce.number().int().min(0).default(10000),
55
+ SLOW_QUERY_MS: z.coerce.number().int().min(0).default(1000),
56
+ METRICS_ENABLED: z.stringbool().default(false),
57
+ METRICS_ENDPOINT: z.string().default("/metrics"),
58
+ METRICS_SECRET: secretList,
59
+ METRICS_MAX_OPERATION_LABELS: z.coerce.number().int().min(1).default(200),
60
+ // Only the gate is graphoria's own. The rest are the standard OpenTelemetry
61
+ // names, so a cluster that already injects them into its pods needs to set
62
+ // nothing but TRACING_ENABLED.
63
+ TRACING_ENABLED: z.stringbool().default(false),
64
+ OTEL_EXPORTER_OTLP_ENDPOINT: z.string().default("http://localhost:4318"),
65
+ OTEL_EXPORTER_OTLP_HEADERS: z.string().default(""),
66
+ OTEL_SERVICE_NAME: z.string().default("graphoria"),
67
+ OTEL_TRACES_SAMPLER_ARG: z.coerce.number().min(0).max(1).default(1),
55
68
  MAX_QUERY_COST: z.coerce.number().int().min(0).default(0),
56
69
  RATE_LIMIT_MAX: z.coerce.number().int().min(0).default(0),
57
70
  RATE_LIMIT_ANONYMOUS_MAX: z.coerce.number().int().min(0).default(0),
@@ -92,6 +105,7 @@ export const EnvZod = z
92
105
  defaultPageSize: env.DEFAULT_PAGE_SIZE,
93
106
  maxPageSize: env.MAX_PAGE_SIZE,
94
107
  queryTimeoutMs: env.QUERY_TIMEOUT_MS,
108
+ slowQueryMs: env.SLOW_QUERY_MS,
95
109
  maxQueryCost: env.MAX_QUERY_COST,
96
110
  admin: {
97
111
  secrets: env.ADMIN_SECRET,
@@ -124,6 +138,19 @@ export const EnvZod = z
124
138
  superadmin: {
125
139
  role: env.SUPERADMIN_ROLE,
126
140
  },
141
+ metrics: {
142
+ enabled: env.METRICS_ENABLED,
143
+ endpoint: env.METRICS_ENDPOINT,
144
+ secrets: env.METRICS_SECRET,
145
+ maxOperationLabels: env.METRICS_MAX_OPERATION_LABELS,
146
+ },
147
+ tracing: {
148
+ enabled: env.TRACING_ENABLED,
149
+ endpoint: env.OTEL_EXPORTER_OTLP_ENDPOINT,
150
+ headers: env.OTEL_EXPORTER_OTLP_HEADERS,
151
+ serviceName: env.OTEL_SERVICE_NAME,
152
+ sampleRatio: env.OTEL_TRACES_SAMPLER_ARG,
153
+ },
127
154
  console: {
128
155
  enabled: env.CONSOLE_ENABLED,
129
156
  endpoint: env.CONSOLE_ENDPOINT,
@@ -1,6 +1,7 @@
1
1
  import { LRUCache } from "lru-cache";
2
2
 
3
3
  import { logger } from "../logging";
4
+ import { incMetric } from "../observability/metrics";
4
5
 
5
6
  export type ConsumeResult = { allowed: boolean; retryAfterMs: number };
6
7
 
@@ -203,7 +204,11 @@ export const createRateLimiter = ({
203
204
  const key =
204
205
  sub && sub !== "anonymous" ? `rl:${role}:${sub}` : `rl:${role}:ip:${address ?? "unknown"}`;
205
206
 
206
- return resolvedStore.consume(key, max, max / windowMs, windowMs);
207
+ const verdict = await resolvedStore.consume(key, max, max / windowMs, windowMs);
208
+
209
+ if (!verdict.allowed) incMetric("graphoria_rate_limit_rejections_total", { role });
210
+
211
+ return verdict;
207
212
  },
208
213
  };
209
214
  };
@@ -0,0 +1,71 @@
1
+ import { RedisClient as ValkeyClient } from "bun";
2
+
3
+ import { logger } from "../logging";
4
+
5
+ const RECONNECT_INITIAL_DELAY = 1000;
6
+ const RECONNECT_MAX_DELAY = 30000;
7
+ const RECONNECT_MULTIPLIER = 2;
8
+
9
+ export type RevivableRedisClient = {
10
+ onclose: ((error: Error) => void) | null;
11
+ connect(): Promise<void>;
12
+ };
13
+
14
+ export type KeepRedisConnectedOptions = {
15
+ setTimeout?: typeof setTimeout;
16
+ };
17
+
18
+ /**
19
+ * Bun's client reconnects on its own only until `maxRetries` runs out; then it
20
+ * fires `onclose` and fails every later command with "Connection has failed",
21
+ * even once the server is back. Only an explicit `connect()` revives it, so
22
+ * that is what `onclose` schedules here, backing off while the server stays down.
23
+ */
24
+ export const keepRedisConnected = (
25
+ client: RevivableRedisClient,
26
+ options: KeepRedisConnectedOptions = {},
27
+ ) => {
28
+ const log = logger("redis");
29
+ const setTimeoutFn = options.setTimeout ?? setTimeout;
30
+
31
+ let attempts = 0;
32
+ let reconnecting = false;
33
+
34
+ const scheduleReconnect = () => {
35
+ if (reconnecting) return;
36
+ reconnecting = true;
37
+
38
+ const delay = Math.min(
39
+ RECONNECT_INITIAL_DELAY * Math.pow(RECONNECT_MULTIPLIER, attempts),
40
+ RECONNECT_MAX_DELAY,
41
+ );
42
+ attempts++;
43
+
44
+ setTimeoutFn(() => {
45
+ client.connect().then(
46
+ () => {
47
+ reconnecting = false;
48
+ attempts = 0;
49
+ log.info("reconnected");
50
+ },
51
+ (error: unknown) => {
52
+ reconnecting = false;
53
+ log.warn({ err: error, attempt: attempts }, "reconnect attempt failed");
54
+ scheduleReconnect();
55
+ },
56
+ );
57
+ }, delay).unref();
58
+ };
59
+
60
+ client.onclose = (error) => {
61
+ if (reconnecting) return;
62
+ log.warn({ err: error }, "connection lost, reconnecting");
63
+ scheduleReconnect();
64
+ };
65
+ };
66
+
67
+ export const createRedisClient = (url: string) => {
68
+ const client = new ValkeyClient(url);
69
+ keepRedisConnected(client);
70
+ return client;
71
+ };
@@ -56,3 +56,9 @@ export class S500 extends ClientResponse {
56
56
  super(body, { ...init, status: 500 });
57
57
  }
58
58
  }
59
+
60
+ export class S503 extends ClientResponse {
61
+ constructor(body?: object | null, init?: ResponseInit) {
62
+ super(body, { ...init, status: 503 });
63
+ }
64
+ }