@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.
- package/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.ts +41 -24
- package/Models/AnalyticsModels/MetricBaselineHourly.ts +2 -0
- package/Models/AnalyticsModels/MetricItemAggMV1m.ts +5 -2
- package/Models/AnalyticsModels/MetricItemAggMV1mByContainer.ts +5 -2
- package/Models/AnalyticsModels/MetricItemAggMV1mByHostV2.ts +5 -2
- package/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.ts +5 -2
- package/Models/AnalyticsModels/MetricItemAggMV1mByService.ts +5 -2
- package/Models/DatabaseModels/EnterpriseLicense.ts +21 -0
- package/Models/DatabaseModels/EnterpriseLicenseInstance.ts +25 -0
- package/Models/DatabaseModels/GlobalConfig.ts +76 -0
- package/Server/API/EnterpriseLicenseAPI.ts +57 -0
- package/Server/API/GlobalConfigAPI.ts +62 -1
- package/Server/EnvironmentConfig.ts +22 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.ts +38 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.ts +25 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Services/AnalyticsDatabaseService.ts +61 -46
- package/Server/Services/DatabaseService.ts +66 -1
- package/Server/Services/WorkflowService.ts +100 -39
- package/Server/Types/Workflow/Components/Schedule.ts +57 -32
- package/Server/Utils/AnalyticsDatabase/InsertDedupContext.ts +59 -0
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +41 -4
- package/Server/Utils/Express.ts +13 -0
- package/Server/Utils/Telemetry/TelemetryFanInWriter.ts +799 -0
- package/Server/Utils/Telemetry/TelemetryWriterClient.ts +263 -0
- package/Server/Utils/Telemetry/TelemetryWriterServer.ts +240 -0
- package/Server/Utils/Telemetry/TelemetryWriterShedMetrics.ts +93 -0
- package/Tests/Models/AnalyticsModels/AnalyticsBaseModel.test.ts +122 -0
- package/Tests/Server/Services/DatabaseServiceSanitizeUpdateData.test.ts +232 -0
- package/Tests/Server/Services/UpdateOneByIdKeepsRowId.test.ts +107 -0
- package/Tests/Server/Services/WorkflowService.test.ts +308 -0
- package/Tests/Server/Utils/AnalyticsDatabase/ClusterAwareSchema.test.ts +164 -3
- package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +99 -0
- package/Tests/Server/Utils/Attribution.test.ts +203 -0
- package/Tests/Server/Utils/Marketing/ConversionUploadProvider.test.ts +183 -0
- package/Tests/Server/Utils/Marketing/GoogleAds.test.ts +340 -0
- package/Tests/Server/Utils/Monitor/Criteria/ExceptionMonitorCriteria.test.ts +87 -0
- package/Tests/Server/Utils/Monitor/Criteria/LogMonitorCriteria.test.ts +165 -0
- package/Tests/Server/Utils/Monitor/Criteria/TraceMonitorCriteria.test.ts +86 -0
- package/Tests/Server/Utils/Telemetry/TelemetryFanInWriter.test.ts +1565 -0
- package/Tests/Server/Utils/Telemetry/TelemetryWriterClient.test.ts +313 -0
- package/Tests/Server/Utils/Telemetry/TelemetryWriterServer.test.ts +270 -0
- package/Tests/Server/Utils/Telemetry/TelemetryWriterShedMetrics.test.ts +127 -0
- package/Tests/Types/AI/CodeFixTaskType.test.ts +3 -0
- package/Tests/Types/HashCode.test.ts +41 -0
- package/Tests/Types/Log/LogQueryParser.test.ts +186 -0
- package/Tests/Types/Monitor/MonitorCriteriaMetricVariables.test.ts +228 -0
- package/Tests/Types/Monitor/MonitorStepDefaultTelemetryConfig.test.ts +169 -0
- package/Tests/Types/Monitor/MonitorStepExceptionMonitor.test.ts +289 -0
- package/Tests/Types/Monitor/MonitorStepLogMonitor.test.ts +231 -0
- package/Tests/Types/Monitor/MonitorStepMetricViewConfigUtil.test.ts +245 -0
- package/Tests/Types/Monitor/MonitorStepNetworkDeviceMonitor.test.ts +155 -0
- package/Tests/Types/Monitor/MonitorStepTelemetrySerialization.test.ts +141 -0
- package/Tests/Types/Monitor/MonitorStepTraceMonitor.test.ts +137 -0
- package/Tests/Types/Workspace/NotificationRules/NotificationRuleCondition.test.ts +311 -0
- package/Tests/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.test.ts +461 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +2852 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +758 -0
- package/Tests/UI/Utils/NotificationMethodUtil.test.ts +564 -0
- package/Tests/Utils/Array.test.ts +156 -0
- package/Tests/Utils/CronTab.test.ts +199 -0
- package/Tests/Utils/Dashboard/VariableInterpolation.test.ts +197 -0
- package/Tests/Utils/ModelImportExport.test.ts +101 -0
- package/Tests/Utils/Number.test.ts +179 -0
- package/Tests/Utils/ValueFormatter.test.ts +305 -0
- package/Tests/Utils/VersionUtil.test.ts +348 -0
- package/Tests/jest.setup.ts +1 -0
- package/Types/AnalyticsDatabase/TableColumn.ts +23 -0
- package/Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary.ts +6 -0
- package/Types/Monitor/MonitorStep.ts +24 -4
- package/Types/Monitor/MonitorStepMetricViewConfigUtil.ts +105 -0
- package/UI/Components/EditionLabel/EditionLabel.tsx +445 -12
- package/UI/Components/Workflow/ArgumentsForm.tsx +68 -30
- package/UI/Components/Workflow/CronScheduleField.tsx +411 -0
- package/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.ts +176 -0
- package/UI/Utils/NotificationMethodUtil.ts +310 -0
- package/Utils/CronTab.ts +823 -0
- package/Utils/ModelImportExport.ts +21 -6
- package/Utils/VersionUtil.ts +260 -0
- package/build/dist/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.js +25 -20
- package/build/dist/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/MetricBaselineHourly.js +2 -0
- package/build/dist/Models/AnalyticsModels/MetricBaselineHourly.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1m.js +5 -2
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1m.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByContainer.js +5 -2
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByContainer.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByHostV2.js +5 -2
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByHostV2.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.js +5 -2
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByService.js +5 -2
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByService.js.map +1 -1
- package/build/dist/Models/DatabaseModels/EnterpriseLicense.js +22 -0
- package/build/dist/Models/DatabaseModels/EnterpriseLicense.js.map +1 -1
- package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js +26 -0
- package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js.map +1 -1
- package/build/dist/Models/DatabaseModels/GlobalConfig.js +81 -0
- package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
- package/build/dist/Server/API/EnterpriseLicenseAPI.js +47 -0
- package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
- package/build/dist/Server/API/GlobalConfigAPI.js +52 -1
- package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +17 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.js +18 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.js +14 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js +53 -17
- package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +53 -1
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/WorkflowService.js +80 -30
- package/build/dist/Server/Services/WorkflowService.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/Schedule.js +44 -19
- package/build/dist/Server/Types/Workflow/Components/Schedule.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/InsertDedupContext.js +24 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/InsertDedupContext.js.map +1 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +29 -4
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
- package/build/dist/Server/Utils/Express.js +12 -0
- package/build/dist/Server/Utils/Express.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/TelemetryFanInWriter.js +496 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryFanInWriter.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryWriterClient.js +165 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryWriterClient.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryWriterServer.js +136 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryWriterServer.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryWriterShedMetrics.js +76 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryWriterShedMetrics.js.map +1 -0
- package/build/dist/Types/AnalyticsDatabase/TableColumn.js +7 -0
- package/build/dist/Types/AnalyticsDatabase/TableColumn.js.map +1 -1
- package/build/dist/Types/Monitor/MonitorStep.js +20 -4
- package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
- package/build/dist/Types/Monitor/MonitorStepMetricViewConfigUtil.js +73 -0
- package/build/dist/Types/Monitor/MonitorStepMetricViewConfigUtil.js.map +1 -0
- package/build/dist/UI/Components/EditionLabel/EditionLabel.js +261 -13
- package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js +30 -6
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
- package/build/dist/UI/Components/Workflow/CronScheduleField.js +209 -0
- package/build/dist/UI/Components/Workflow/CronScheduleField.js.map +1 -0
- package/build/dist/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.js +129 -0
- package/build/dist/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.js.map +1 -0
- package/build/dist/UI/Utils/NotificationMethodUtil.js +189 -0
- package/build/dist/UI/Utils/NotificationMethodUtil.js.map +1 -0
- package/build/dist/Utils/CronTab.js +609 -0
- package/build/dist/Utils/CronTab.js.map +1 -0
- package/build/dist/Utils/ModelImportExport.js +20 -6
- package/build/dist/Utils/ModelImportExport.js.map +1 -1
- package/build/dist/Utils/VersionUtil.js +193 -0
- package/build/dist/Utils/VersionUtil.js.map +1 -0
- 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
|
+
}
|