@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
package/src/queues/kafka.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
},
|
package/src/queues/rabbitmq.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/remoteREST/proxy.ts
CHANGED
|
@@ -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:
|
|
4
|
+
let client: ReturnType<typeof createRedisClient> | null = null;
|
|
6
5
|
|
|
7
|
-
export const getCacheRedisClient = ():
|
|
6
|
+
export const getCacheRedisClient = (): ReturnType<typeof createRedisClient> => {
|
|
8
7
|
if (!client) {
|
|
9
|
-
client =
|
|
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
|
package/src/singletons/queues.ts
CHANGED
|
@@ -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 = {
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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 =
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
}
|
|
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,
|
package/src/utils/rateLimit.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|
package/src/utils/responses.ts
CHANGED