@oneuptime/common 11.5.13 → 11.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.ts +41 -24
  2. package/Models/AnalyticsModels/MetricBaselineHourly.ts +2 -0
  3. package/Models/AnalyticsModels/MetricItemAggMV1m.ts +5 -2
  4. package/Models/AnalyticsModels/MetricItemAggMV1mByContainer.ts +5 -2
  5. package/Models/AnalyticsModels/MetricItemAggMV1mByHostV2.ts +5 -2
  6. package/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.ts +5 -2
  7. package/Models/AnalyticsModels/MetricItemAggMV1mByService.ts +5 -2
  8. package/Models/DatabaseModels/EnterpriseLicense.ts +21 -0
  9. package/Models/DatabaseModels/EnterpriseLicenseInstance.ts +25 -0
  10. package/Models/DatabaseModels/GlobalConfig.ts +76 -0
  11. package/Server/API/EnterpriseLicenseAPI.ts +57 -0
  12. package/Server/API/GlobalConfigAPI.ts +62 -1
  13. package/Server/EnvironmentConfig.ts +22 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.ts +38 -0
  15. package/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.ts +25 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  17. package/Server/Services/AnalyticsDatabaseService.ts +61 -46
  18. package/Server/Services/DatabaseService.ts +66 -1
  19. package/Server/Services/WorkflowService.ts +100 -39
  20. package/Server/Types/Workflow/Components/Schedule.ts +57 -32
  21. package/Server/Utils/AnalyticsDatabase/InsertDedupContext.ts +59 -0
  22. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +41 -4
  23. package/Server/Utils/Express.ts +13 -0
  24. package/Server/Utils/Telemetry/TelemetryFanInWriter.ts +799 -0
  25. package/Server/Utils/Telemetry/TelemetryWriterClient.ts +263 -0
  26. package/Server/Utils/Telemetry/TelemetryWriterServer.ts +240 -0
  27. package/Server/Utils/Telemetry/TelemetryWriterShedMetrics.ts +93 -0
  28. package/Tests/Models/AnalyticsModels/AnalyticsBaseModel.test.ts +122 -0
  29. package/Tests/Server/Services/DatabaseServiceSanitizeUpdateData.test.ts +232 -0
  30. package/Tests/Server/Services/UpdateOneByIdKeepsRowId.test.ts +107 -0
  31. package/Tests/Server/Services/WorkflowService.test.ts +308 -0
  32. package/Tests/Server/Utils/AnalyticsDatabase/ClusterAwareSchema.test.ts +164 -3
  33. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +99 -0
  34. package/Tests/Server/Utils/Attribution.test.ts +203 -0
  35. package/Tests/Server/Utils/Marketing/ConversionUploadProvider.test.ts +183 -0
  36. package/Tests/Server/Utils/Marketing/GoogleAds.test.ts +340 -0
  37. package/Tests/Server/Utils/Monitor/Criteria/ExceptionMonitorCriteria.test.ts +87 -0
  38. package/Tests/Server/Utils/Monitor/Criteria/LogMonitorCriteria.test.ts +165 -0
  39. package/Tests/Server/Utils/Monitor/Criteria/TraceMonitorCriteria.test.ts +86 -0
  40. package/Tests/Server/Utils/Telemetry/TelemetryFanInWriter.test.ts +1565 -0
  41. package/Tests/Server/Utils/Telemetry/TelemetryWriterClient.test.ts +313 -0
  42. package/Tests/Server/Utils/Telemetry/TelemetryWriterServer.test.ts +270 -0
  43. package/Tests/Server/Utils/Telemetry/TelemetryWriterShedMetrics.test.ts +127 -0
  44. package/Tests/Types/AI/CodeFixTaskType.test.ts +3 -0
  45. package/Tests/Types/HashCode.test.ts +41 -0
  46. package/Tests/Types/Log/LogQueryParser.test.ts +186 -0
  47. package/Tests/Types/Monitor/MonitorCriteriaMetricVariables.test.ts +228 -0
  48. package/Tests/Types/Monitor/MonitorStepDefaultTelemetryConfig.test.ts +169 -0
  49. package/Tests/Types/Monitor/MonitorStepExceptionMonitor.test.ts +289 -0
  50. package/Tests/Types/Monitor/MonitorStepLogMonitor.test.ts +231 -0
  51. package/Tests/Types/Monitor/MonitorStepMetricViewConfigUtil.test.ts +245 -0
  52. package/Tests/Types/Monitor/MonitorStepNetworkDeviceMonitor.test.ts +155 -0
  53. package/Tests/Types/Monitor/MonitorStepTelemetrySerialization.test.ts +141 -0
  54. package/Tests/Types/Monitor/MonitorStepTraceMonitor.test.ts +137 -0
  55. package/Tests/Types/Workspace/NotificationRules/NotificationRuleCondition.test.ts +311 -0
  56. package/Tests/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.test.ts +461 -0
  57. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +2852 -0
  58. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +758 -0
  59. package/Tests/UI/Utils/NotificationMethodUtil.test.ts +564 -0
  60. package/Tests/Utils/Array.test.ts +156 -0
  61. package/Tests/Utils/CronTab.test.ts +199 -0
  62. package/Tests/Utils/Dashboard/VariableInterpolation.test.ts +197 -0
  63. package/Tests/Utils/ModelImportExport.test.ts +101 -0
  64. package/Tests/Utils/Number.test.ts +179 -0
  65. package/Tests/Utils/ValueFormatter.test.ts +305 -0
  66. package/Tests/Utils/VersionUtil.test.ts +348 -0
  67. package/Tests/jest.setup.ts +1 -0
  68. package/Types/AnalyticsDatabase/TableColumn.ts +23 -0
  69. package/Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary.ts +6 -0
  70. package/Types/Monitor/MonitorStep.ts +24 -4
  71. package/Types/Monitor/MonitorStepMetricViewConfigUtil.ts +105 -0
  72. package/UI/Components/EditionLabel/EditionLabel.tsx +445 -12
  73. package/UI/Components/Workflow/ArgumentsForm.tsx +68 -30
  74. package/UI/Components/Workflow/CronScheduleField.tsx +411 -0
  75. package/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.ts +176 -0
  76. package/UI/Utils/NotificationMethodUtil.ts +310 -0
  77. package/Utils/CronTab.ts +823 -0
  78. package/Utils/ModelImportExport.ts +21 -6
  79. package/Utils/VersionUtil.ts +260 -0
  80. package/build/dist/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.js +25 -20
  81. package/build/dist/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.js.map +1 -1
  82. package/build/dist/Models/AnalyticsModels/MetricBaselineHourly.js +2 -0
  83. package/build/dist/Models/AnalyticsModels/MetricBaselineHourly.js.map +1 -1
  84. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1m.js +5 -2
  85. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1m.js.map +1 -1
  86. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByContainer.js +5 -2
  87. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByContainer.js.map +1 -1
  88. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByHostV2.js +5 -2
  89. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByHostV2.js.map +1 -1
  90. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.js +5 -2
  91. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.js.map +1 -1
  92. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByService.js +5 -2
  93. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByService.js.map +1 -1
  94. package/build/dist/Models/DatabaseModels/EnterpriseLicense.js +22 -0
  95. package/build/dist/Models/DatabaseModels/EnterpriseLicense.js.map +1 -1
  96. package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js +26 -0
  97. package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js.map +1 -1
  98. package/build/dist/Models/DatabaseModels/GlobalConfig.js +81 -0
  99. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  100. package/build/dist/Server/API/EnterpriseLicenseAPI.js +47 -0
  101. package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
  102. package/build/dist/Server/API/GlobalConfigAPI.js +52 -1
  103. package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
  104. package/build/dist/Server/EnvironmentConfig.js +17 -0
  105. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  106. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.js +18 -0
  107. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.js.map +1 -0
  108. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.js +14 -0
  109. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.js.map +1 -0
  110. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  111. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  112. package/build/dist/Server/Services/AnalyticsDatabaseService.js +53 -17
  113. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  114. package/build/dist/Server/Services/DatabaseService.js +53 -1
  115. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  116. package/build/dist/Server/Services/WorkflowService.js +80 -30
  117. package/build/dist/Server/Services/WorkflowService.js.map +1 -1
  118. package/build/dist/Server/Types/Workflow/Components/Schedule.js +44 -19
  119. package/build/dist/Server/Types/Workflow/Components/Schedule.js.map +1 -1
  120. package/build/dist/Server/Utils/AnalyticsDatabase/InsertDedupContext.js +24 -0
  121. package/build/dist/Server/Utils/AnalyticsDatabase/InsertDedupContext.js.map +1 -0
  122. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +29 -4
  123. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  124. package/build/dist/Server/Utils/Express.js +12 -0
  125. package/build/dist/Server/Utils/Express.js.map +1 -1
  126. package/build/dist/Server/Utils/Telemetry/TelemetryFanInWriter.js +496 -0
  127. package/build/dist/Server/Utils/Telemetry/TelemetryFanInWriter.js.map +1 -0
  128. package/build/dist/Server/Utils/Telemetry/TelemetryWriterClient.js +165 -0
  129. package/build/dist/Server/Utils/Telemetry/TelemetryWriterClient.js.map +1 -0
  130. package/build/dist/Server/Utils/Telemetry/TelemetryWriterServer.js +136 -0
  131. package/build/dist/Server/Utils/Telemetry/TelemetryWriterServer.js.map +1 -0
  132. package/build/dist/Server/Utils/Telemetry/TelemetryWriterShedMetrics.js +76 -0
  133. package/build/dist/Server/Utils/Telemetry/TelemetryWriterShedMetrics.js.map +1 -0
  134. package/build/dist/Types/AnalyticsDatabase/TableColumn.js +7 -0
  135. package/build/dist/Types/AnalyticsDatabase/TableColumn.js.map +1 -1
  136. package/build/dist/Types/Monitor/MonitorStep.js +20 -4
  137. package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
  138. package/build/dist/Types/Monitor/MonitorStepMetricViewConfigUtil.js +73 -0
  139. package/build/dist/Types/Monitor/MonitorStepMetricViewConfigUtil.js.map +1 -0
  140. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +261 -13
  141. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  142. package/build/dist/UI/Components/Workflow/ArgumentsForm.js +30 -6
  143. package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
  144. package/build/dist/UI/Components/Workflow/CronScheduleField.js +209 -0
  145. package/build/dist/UI/Components/Workflow/CronScheduleField.js.map +1 -0
  146. package/build/dist/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.js +129 -0
  147. package/build/dist/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.js.map +1 -0
  148. package/build/dist/UI/Utils/NotificationMethodUtil.js +189 -0
  149. package/build/dist/UI/Utils/NotificationMethodUtil.js.map +1 -0
  150. package/build/dist/Utils/CronTab.js +609 -0
  151. package/build/dist/Utils/CronTab.js.map +1 -0
  152. package/build/dist/Utils/ModelImportExport.js +20 -6
  153. package/build/dist/Utils/ModelImportExport.js.map +1 -1
  154. package/build/dist/Utils/VersionUtil.js +193 -0
  155. package/build/dist/Utils/VersionUtil.js.map +1 -0
  156. package/package.json +1 -1
@@ -0,0 +1,263 @@
1
+ import ClusterKeyAuthorization from "../../Middleware/ClusterKeyAuthorization";
2
+ import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
3
+ import HTTPResponse from "../../../Types/API/HTTPResponse";
4
+ import URL from "../../../Types/API/URL";
5
+ import { JSONObject } from "../../../Types/JSON";
6
+ import API from "../../../Utils/API";
7
+ import {
8
+ FanInInsertTarget,
9
+ FanInInsertTransport,
10
+ TransientInsertError,
11
+ } from "./TelemetryFanInWriter";
12
+ import { type ClickHouseSettings } from "@clickhouse/client";
13
+
14
+ /*
15
+ * Worker-side client for the telemetry-writer tier.
16
+ *
17
+ * When TELEMETRY_WRITER_URL is set on a pod, its fan-in writer delivers
18
+ * every token group as one HTTP POST to the writer tier instead of
19
+ * inserting into ClickHouse itself. This decouples worker replica count
20
+ * from ClickHouse INSERT concurrency: telemetry insert load on ClickHouse
21
+ * is (writerReplicas × TELEMETRY_FANIN_MAX_CONCURRENT_INSERTS) regardless
22
+ * of how far the worker fleet scales out. (Reads and low-volume direct
23
+ * inserts that never go through the fan-in writer still use this pod's own
24
+ * ClickHouse pools.)
25
+ *
26
+ * Failure classification maps onto the fan-in writer's retry semantics:
27
+ * every payload carries a dedup token that is byte-identical across
28
+ * retries, so any AMBIGUOUS outcome (network error, timeout, writer pod
29
+ * dying mid-request) is safe to classify transient — a retry of a payload
30
+ * that actually landed is dropped server-side by ClickHouse. Only a
31
+ * definitive verdict from the writer (2xx success, 4xx/500 permanent
32
+ * failure) is treated as final.
33
+ */
34
+
35
+ export const TELEMETRY_WRITER_INSERT_ROUTE: string = "/telemetry-writer/insert";
36
+
37
+ export function getTelemetryWriterUrl(): string | null {
38
+ const raw: string | undefined = process.env["TELEMETRY_WRITER_URL"];
39
+ const trimmed: string = (raw || "").trim();
40
+ if (!trimmed) {
41
+ return null;
42
+ }
43
+ // Normalize away a trailing slash so route concatenation is predictable.
44
+ return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
45
+ }
46
+
47
+ export function isTelemetryWriterForwardingEnabled(): boolean {
48
+ return getTelemetryWriterUrl() !== null;
49
+ }
50
+
51
+ export function getTelemetryWriterRequestTimeoutMs(): number {
52
+ const raw: string | undefined =
53
+ process.env["TELEMETRY_WRITER_REQUEST_TIMEOUT_MS"];
54
+ const parsed: number = parseInt(raw || "", 10);
55
+ /*
56
+ * Generous (the writer holds the request until ClickHouse accepts the
57
+ * rows, which under load includes its batching wait and retry loop) but
58
+ * deliberately under the BullMQ job lock: the fan-in writer makes up to
59
+ * 6 attempts, and 6 x 90s + ~8s backoff ≈ 9.2 min must stay inside
60
+ * TELEMETRY_LOCK_DURATION_MS (10 min) — otherwise a saturated writer
61
+ * tier stalls jobs into redelivery churn instead of failing them
62
+ * cleanly for retry.
63
+ */
64
+ return !isNaN(parsed) && parsed > 0 ? parsed : 90_000;
65
+ }
66
+
67
+ export function getTelemetryWriterMaxBodyBytes(): number {
68
+ const raw: string | undefined =
69
+ process.env["TELEMETRY_WRITER_MAX_BODY_BYTES"];
70
+ const parsed: number = parseInt(raw || "", 10);
71
+ /*
72
+ * Stay well under the writer route's 50 MB express.json limit (plus
73
+ * headroom for headers/encoding). Bodies above this are split before
74
+ * sending — a 413 would be classified permanent and drop rows.
75
+ */
76
+ return !isNaN(parsed) && parsed > 0 ? parsed : 30_000_000;
77
+ }
78
+
79
+ export interface WriterPostResult {
80
+ statusCode: number;
81
+ errorMessage?: string | undefined;
82
+ }
83
+
84
+ export type WriterPostFn = (args: {
85
+ url: string;
86
+ body: JSONObject;
87
+ timeoutMs: number;
88
+ }) => Promise<WriterPostResult>;
89
+
90
+ const defaultPost: WriterPostFn = async (args: {
91
+ url: string;
92
+ body: JSONObject;
93
+ timeoutMs: number;
94
+ }): Promise<WriterPostResult> => {
95
+ let response: HTTPResponse<JSONObject> | HTTPErrorResponse;
96
+ try {
97
+ response = await API.post<JSONObject>({
98
+ url: URL.fromString(args.url),
99
+ data: args.body,
100
+ headers: {
101
+ ...ClusterKeyAuthorization.getClusterKeyHeaders(),
102
+ },
103
+ options: {
104
+ timeout: args.timeoutMs,
105
+ skipAuthRefresh: true,
106
+ },
107
+ });
108
+ } catch (err) {
109
+ /*
110
+ * API.post throws (APIException) when no HTTP response exists at all —
111
+ * connection refused, reset, DNS, timeout. Outcome unknown; statusCode 0
112
+ * classifies it transient.
113
+ */
114
+ return {
115
+ statusCode: 0,
116
+ errorMessage: err instanceof Error ? err.message : String(err),
117
+ };
118
+ }
119
+
120
+ if (response instanceof HTTPErrorResponse) {
121
+ return {
122
+ statusCode: response.statusCode,
123
+ errorMessage: response.message,
124
+ };
125
+ }
126
+
127
+ return { statusCode: response.statusCode };
128
+ };
129
+
130
+ /*
131
+ * Writer-tier verdicts that mean "retry with the same token": overload
132
+ * shedding (429) and gateway/unavailability statuses where the request may
133
+ * or may not have been processed.
134
+ */
135
+ const TRANSIENT_HTTP_STATUSES: Set<number> = new Set([429, 502, 503, 504]);
136
+
137
+ function isDefinitiveStatus(statusCode: number): boolean {
138
+ /*
139
+ * Anything outside real HTTP semantics (0, -1, undefined coerced) is a
140
+ * network-level failure with an unknown outcome — transient by the dedup
141
+ * token argument above.
142
+ */
143
+ return statusCode >= 100 && statusCode < 600;
144
+ }
145
+
146
+ export function createTelemetryWriterTransport(
147
+ postFn?: WriterPostFn,
148
+ ): FanInInsertTransport {
149
+ const post: WriterPostFn = postFn ?? defaultPost;
150
+
151
+ const postGroup: (
152
+ tableName: string,
153
+ rows: Array<JSONObject>,
154
+ dedupToken: string,
155
+ clickhouseSettings: ClickHouseSettings | undefined,
156
+ baseUrl: string,
157
+ maxBodyBytes: number,
158
+ ) => Promise<void> = async (
159
+ tableName: string,
160
+ rows: Array<JSONObject>,
161
+ dedupToken: string,
162
+ clickhouseSettings: ClickHouseSettings | undefined,
163
+ baseUrl: string,
164
+ maxBodyBytes: number,
165
+ ): Promise<void> => {
166
+ const body: JSONObject = {
167
+ tableName: tableName,
168
+ rows: rows,
169
+ dedupToken: dedupToken,
170
+ ...(clickhouseSettings
171
+ ? { clickhouseSettings: clickhouseSettings as JSONObject }
172
+ : {}),
173
+ };
174
+
175
+ /*
176
+ * Byte-aware splitting: the writer route sits behind a 50 MB JSON
177
+ * parser, and a 413 is (correctly) classified permanent — so an
178
+ * oversized group would be dropped after retries, a failure mode the
179
+ * direct ClickHouse path never had. Halve deterministically instead:
180
+ * a BullMQ retry re-produces byte-identical rows, hence the identical
181
+ * split and identical derived tokens ("<token>#0"/"<token>#1"), so
182
+ * idempotence is preserved (async-insert dedup is a content hash per
183
+ * insert body, and the distinct suffixes keep tokens collision-free
184
+ * for any future sync-insert mode). Halves post sequentially — this
185
+ * all happens under one fan-in insert slot, and splitting must not
186
+ * multiply the pod's concurrent load.
187
+ */
188
+ if (rows.length > 1) {
189
+ const bodyBytes: number = Buffer.byteLength(JSON.stringify(body), "utf8");
190
+ if (bodyBytes > maxBodyBytes) {
191
+ const mid: number = Math.floor(rows.length / 2);
192
+ await postGroup(
193
+ tableName,
194
+ rows.slice(0, mid),
195
+ `${dedupToken}#0`,
196
+ clickhouseSettings,
197
+ baseUrl,
198
+ maxBodyBytes,
199
+ );
200
+ await postGroup(
201
+ tableName,
202
+ rows.slice(mid),
203
+ `${dedupToken}#1`,
204
+ clickhouseSettings,
205
+ baseUrl,
206
+ maxBodyBytes,
207
+ );
208
+ return;
209
+ }
210
+ }
211
+
212
+ const result: WriterPostResult = await post({
213
+ url: `${baseUrl}${TELEMETRY_WRITER_INSERT_ROUTE}`,
214
+ body: body,
215
+ timeoutMs: getTelemetryWriterRequestTimeoutMs(),
216
+ });
217
+
218
+ if (result.statusCode >= 200 && result.statusCode < 300) {
219
+ return;
220
+ }
221
+
222
+ const detail: string = `telemetry-writer insert into ${tableName} (${rows.length} rows) returned status ${result.statusCode}${result.errorMessage ? `: ${result.errorMessage}` : ""}`;
223
+
224
+ if (
225
+ TRANSIENT_HTTP_STATUSES.has(result.statusCode) ||
226
+ !isDefinitiveStatus(result.statusCode)
227
+ ) {
228
+ throw new TransientInsertError(detail);
229
+ }
230
+
231
+ throw new Error(detail);
232
+ };
233
+
234
+ return async (
235
+ target: FanInInsertTarget,
236
+ rows: Array<JSONObject>,
237
+ options: {
238
+ dedupToken: string;
239
+ clickhouseSettings: ClickHouseSettings | undefined;
240
+ },
241
+ ): Promise<void> => {
242
+ const baseUrl: string | null = getTelemetryWriterUrl();
243
+ if (!baseUrl) {
244
+ /*
245
+ * The transport is only installed when the URL is configured; losing
246
+ * it mid-flight (env mutation in tests) is a permanent config error,
247
+ * not something a same-token retry can fix.
248
+ */
249
+ throw new Error(
250
+ "TelemetryWriterClient: TELEMETRY_WRITER_URL is not configured.",
251
+ );
252
+ }
253
+
254
+ await postGroup(
255
+ target.model.tableName,
256
+ rows,
257
+ options.dedupToken,
258
+ options.clickhouseSettings,
259
+ baseUrl,
260
+ getTelemetryWriterMaxBodyBytes(),
261
+ );
262
+ };
263
+ }
@@ -0,0 +1,240 @@
1
+ import { JSONObject } from "../../../Types/JSON";
2
+ import {
3
+ FanInInsertError,
4
+ FanInInsertTarget,
5
+ FanInSubmitResult,
6
+ isRetryableInsertError,
7
+ } from "./TelemetryFanInWriter";
8
+ import { type ClickHouseSettings } from "@clickhouse/client";
9
+
10
+ /*
11
+ * Writer-tier request handling for the telemetry fan-in path, kept free of
12
+ * Express so the admission, validation, and status mapping are unit-testable.
13
+ * The App router (App/FeatureSet/Telemetry/API/TelemetryWriterAPI.ts) is a
14
+ * thin shell: cluster-key auth → inflight gate → handleWriterInsert.
15
+ *
16
+ * Status contract (mirrored by TelemetryWriterClient's classification):
17
+ * - 200: ClickHouse accepted the rows — into its async-insert buffer by
18
+ * default (ClickHouse owns flushing), or durably flushed when
19
+ * TELEMETRY_WAIT_FOR_ASYNC_INSERT=true on the writer pods.
20
+ * - 400: malformed request — never retried.
21
+ * - 429: this pod is at its inflight-request cap — retry (load shedding is
22
+ * the writer tier's flow control at arbitrary worker-fleet sizes; without
23
+ * it, parked request bodies would grow pod memory without bound).
24
+ * - 500: insert failed definitively after the local writer's retries.
25
+ * - 503: retry — a still-transient insert failure after the local writer
26
+ * exhausted its retries, a misconfigured (forwarding) pod, or an unknown
27
+ * table. Unknown tables are 503 rather than 400 because of rolling
28
+ * upgrades: a new-image worker can submit rows for a just-added
29
+ * analytics table to an old-image writer that does not know it yet;
30
+ * retries outlive the skew window and the rows land once the writer
31
+ * rolls, whereas a 400 would permanently drop them.
32
+ */
33
+
34
+ export interface TelemetryWriterInsertRequest {
35
+ tableName: string;
36
+ rows: Array<JSONObject>;
37
+ dedupToken: string;
38
+ clickhouseSettings: ClickHouseSettings | undefined;
39
+ }
40
+
41
+ export interface WriterInsertOutcome {
42
+ statusCode: number;
43
+ body: JSONObject;
44
+ }
45
+
46
+ /*
47
+ * Counting gate for concurrently-served insert requests. Rows a parked
48
+ * request holds are invisible to the fan-in writer's maxPendingRows until
49
+ * buffered, so this cap — not maxPendingRows — is what bounds writer-pod
50
+ * memory when the worker fleet dwarfs the writer tier.
51
+ */
52
+ export class InflightGate {
53
+ private max: number;
54
+ private current: number = 0;
55
+
56
+ public constructor(max: number) {
57
+ this.max = max;
58
+ }
59
+
60
+ public tryAcquire(): boolean {
61
+ if (this.current >= this.max) {
62
+ return false;
63
+ }
64
+ this.current++;
65
+ return true;
66
+ }
67
+
68
+ public release(): void {
69
+ this.current--;
70
+ }
71
+
72
+ public get inflight(): number {
73
+ return this.current;
74
+ }
75
+ }
76
+
77
+ export function readWriterMaxInflightRequestsFromEnv(): number {
78
+ const raw: string | undefined =
79
+ process.env["TELEMETRY_WRITER_MAX_INFLIGHT_REQUESTS"];
80
+ const parsed: number = parseInt(raw || "", 10);
81
+ return !isNaN(parsed) && parsed > 0 ? parsed : 100;
82
+ }
83
+
84
+ export type TableTargetResolver = (
85
+ tableName: string,
86
+ ) => FanInInsertTarget | undefined;
87
+
88
+ /*
89
+ * Build a tableName → insert-target resolver from the analytics-service
90
+ * singletons (Common/Server/Services/Index.ts exports AnalyticsServices).
91
+ * The map is built lazily on first call so import order at boot does not
92
+ * matter.
93
+ */
94
+ export function createTableTargetResolver(
95
+ targets: Array<FanInInsertTarget>,
96
+ ): TableTargetResolver {
97
+ let byTable: Map<string, FanInInsertTarget> | null = null;
98
+
99
+ return (tableName: string): FanInInsertTarget | undefined => {
100
+ if (!byTable) {
101
+ byTable = new Map<string, FanInInsertTarget>();
102
+ for (const target of targets) {
103
+ byTable.set(target.model.tableName, target);
104
+ }
105
+ }
106
+ return byTable.get(tableName);
107
+ };
108
+ }
109
+
110
+ export function parseWriterInsertRequest(
111
+ body: unknown,
112
+ ): TelemetryWriterInsertRequest | { error: string } {
113
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
114
+ return { error: "Request body must be a JSON object." };
115
+ }
116
+
117
+ const json: JSONObject = body as JSONObject;
118
+
119
+ const tableName: unknown = json["tableName"];
120
+ if (typeof tableName !== "string" || tableName.trim() === "") {
121
+ return { error: "tableName must be a non-empty string." };
122
+ }
123
+
124
+ const rows: unknown = json["rows"];
125
+ if (!Array.isArray(rows)) {
126
+ return { error: "rows must be an array." };
127
+ }
128
+ for (const row of rows) {
129
+ if (!row || typeof row !== "object" || Array.isArray(row)) {
130
+ return { error: "rows must contain only JSON objects." };
131
+ }
132
+ }
133
+
134
+ /*
135
+ * The worker-side writer sends a token on EVERY payload (per-job captured
136
+ * or per-batch minted). Requiring it here keeps writer-side retries and
137
+ * ambiguous-network-failure retries duplicate-free by construction.
138
+ */
139
+ const dedupToken: unknown = json["dedupToken"];
140
+ if (typeof dedupToken !== "string" || dedupToken.trim() === "") {
141
+ return { error: "dedupToken must be a non-empty string." };
142
+ }
143
+
144
+ const clickhouseSettings: unknown = json["clickhouseSettings"];
145
+ if (
146
+ clickhouseSettings !== undefined &&
147
+ (typeof clickhouseSettings !== "object" ||
148
+ clickhouseSettings === null ||
149
+ Array.isArray(clickhouseSettings))
150
+ ) {
151
+ return { error: "clickhouseSettings must be an object when present." };
152
+ }
153
+
154
+ return {
155
+ tableName: tableName,
156
+ rows: rows as Array<JSONObject>,
157
+ dedupToken: dedupToken,
158
+ clickhouseSettings: clickhouseSettings as ClickHouseSettings | undefined,
159
+ };
160
+ }
161
+
162
+ export interface WriterInsertDeps {
163
+ resolveTarget: TableTargetResolver;
164
+ submit: (
165
+ target: FanInInsertTarget,
166
+ rows: Array<JSONObject>,
167
+ options: {
168
+ dedupToken: string;
169
+ clickhouseSettings?: ClickHouseSettings | undefined;
170
+ },
171
+ ) => Promise<FanInSubmitResult>;
172
+ /*
173
+ * True when THIS pod is itself configured to forward inserts to a remote
174
+ * writer (TELEMETRY_WRITER_URL set). Serving inserts in that state would
175
+ * bounce payloads between pods indefinitely, so refuse loudly instead.
176
+ */
177
+ forwardingEnabled: boolean;
178
+ }
179
+
180
+ export async function handleWriterInsert(
181
+ body: unknown,
182
+ deps: WriterInsertDeps,
183
+ ): Promise<WriterInsertOutcome> {
184
+ if (deps.forwardingEnabled) {
185
+ return {
186
+ statusCode: 503,
187
+ body: {
188
+ message:
189
+ "This pod forwards telemetry inserts to a remote writer (TELEMETRY_WRITER_URL is set); it cannot serve writer inserts. Point workers at the telemetry-writer deployment instead.",
190
+ },
191
+ };
192
+ }
193
+
194
+ const parsed: TelemetryWriterInsertRequest | { error: string } =
195
+ parseWriterInsertRequest(body);
196
+ if ("error" in parsed) {
197
+ return { statusCode: 400, body: { message: parsed.error } };
198
+ }
199
+
200
+ if (parsed.rows.length === 0) {
201
+ return { statusCode: 200, body: { rowsAccepted: 0 } };
202
+ }
203
+
204
+ const target: FanInInsertTarget | undefined = deps.resolveTarget(
205
+ parsed.tableName,
206
+ );
207
+ if (!target) {
208
+ // 503, not 400: see the status contract — version-skew self-healing.
209
+ return {
210
+ statusCode: 503,
211
+ body: {
212
+ message: `Unknown analytics table: ${parsed.tableName}. If this writer pod is mid-rolling-upgrade, retries will succeed once it runs the newer image.`,
213
+ },
214
+ };
215
+ }
216
+
217
+ try {
218
+ const submission: FanInSubmitResult = await deps.submit(
219
+ target,
220
+ parsed.rows,
221
+ {
222
+ dedupToken: parsed.dedupToken,
223
+ clickhouseSettings: parsed.clickhouseSettings,
224
+ },
225
+ );
226
+ await submission.flushed;
227
+ } catch (err) {
228
+ const message: string = err instanceof Error ? err.message : String(err);
229
+ const stillTransient: boolean =
230
+ err instanceof FanInInsertError
231
+ ? isRetryableInsertError(err.causeError)
232
+ : isRetryableInsertError(err);
233
+ return {
234
+ statusCode: stillTransient ? 503 : 500,
235
+ body: { message: message },
236
+ };
237
+ }
238
+
239
+ return { statusCode: 200, body: { rowsAccepted: parsed.rows.length } };
240
+ }
@@ -0,0 +1,93 @@
1
+ import Redis from "../../Infrastructure/Redis";
2
+ import logger from "../Logger";
3
+
4
+ /*
5
+ * Fleet-wide load-shed counter for the telemetry-writer tier, used as the
6
+ * KEDA autoscaling signal: sustained 429 shedding while ClickHouse is
7
+ * healthy is the one honest "the writer tier is too small" indicator
8
+ * (queue depth is the WORKER fleet's signal and must never scale this
9
+ * tier).
10
+ *
11
+ * Every shed increments a per-minute Redis bucket shared by all writer
12
+ * pods; the metric endpoint sums the current and previous buckets, so any
13
+ * single pod KEDA happens to poll reports the whole tier's shed count for
14
+ * roughly the last one-to-two minutes. Buckets expire after five minutes.
15
+ *
16
+ * Metrics must never break serving: both functions swallow Redis errors
17
+ * (recordShed silently, the reader by returning 0 — which biases KEDA
18
+ * toward NOT scaling on missing data, the safe direction for a tier whose
19
+ * ceiling is a ClickHouse capacity budget).
20
+ */
21
+
22
+ const KEY_PREFIX: string = "telemetry-writer-shed-count";
23
+ const BUCKET_TTL_SECONDS: number = 300;
24
+
25
+ /** Minimal slice of the ioredis client the counter needs; injectable for tests. */
26
+ export interface ShedCounterClient {
27
+ incr(key: string): Promise<number>;
28
+ expire(key: string, seconds: number): Promise<unknown>;
29
+ get(key: string): Promise<string | null>;
30
+ }
31
+
32
+ function getDefaultClient(): ShedCounterClient | null {
33
+ return Redis.getClient();
34
+ }
35
+
36
+ export function shedBucketKey(epochMinute: number): string {
37
+ return `${KEY_PREFIX}:${epochMinute}`;
38
+ }
39
+
40
+ function epochMinute(now: Date): number {
41
+ return Math.floor(now.getTime() / 60_000);
42
+ }
43
+
44
+ export async function recordShed(data?: {
45
+ now?: Date;
46
+ client?: ShedCounterClient | null;
47
+ }): Promise<void> {
48
+ try {
49
+ const client: ShedCounterClient | null =
50
+ data?.client !== undefined ? data.client : getDefaultClient();
51
+ if (!client) {
52
+ return;
53
+ }
54
+ const key: string = shedBucketKey(epochMinute(data?.now ?? new Date()));
55
+ await client.incr(key);
56
+ await client.expire(key, BUCKET_TTL_SECONDS);
57
+ } catch (err) {
58
+ logger.debug("TelemetryWriterShedMetrics: failed to record shed:");
59
+ logger.debug(err as Error);
60
+ }
61
+ }
62
+
63
+ /*
64
+ * Sheds across the whole writer tier in the current + previous minute
65
+ * buckets (~ the last one-to-two minutes). Returns 0 when Redis is
66
+ * unavailable or the buckets are empty/garbled.
67
+ */
68
+ export async function getRecentShedCount(data?: {
69
+ now?: Date;
70
+ client?: ShedCounterClient | null;
71
+ }): Promise<number> {
72
+ try {
73
+ const client: ShedCounterClient | null =
74
+ data?.client !== undefined ? data.client : getDefaultClient();
75
+ if (!client) {
76
+ return 0;
77
+ }
78
+ const currentMinute: number = epochMinute(data?.now ?? new Date());
79
+ let total: number = 0;
80
+ for (const minute of [currentMinute, currentMinute - 1]) {
81
+ const raw: string | null = await client.get(shedBucketKey(minute));
82
+ const parsed: number = parseInt(raw ?? "", 10);
83
+ if (!isNaN(parsed) && parsed > 0) {
84
+ total += parsed;
85
+ }
86
+ }
87
+ return total;
88
+ } catch (err) {
89
+ logger.debug("TelemetryWriterShedMetrics: failed to read shed count:");
90
+ logger.debug(err as Error);
91
+ return 0;
92
+ }
93
+ }