@oneuptime/common 12.0.20 → 12.0.21
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/ChangeEvent.ts +331 -0
- package/Models/AnalyticsModels/Index.ts +2 -0
- package/Server/EnvironmentConfig.ts +1 -8
- package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +0 -2
- package/Server/Services/ChangeEventService.ts +11 -0
- package/Server/Services/EnterpriseLicenseService.ts +3 -3
- package/Server/Services/Index.ts +2 -0
- package/Server/Utils/Attribution.ts +11 -11
- package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
- package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
- package/Server/Views/Partials/AnalyticsConsent.ejs +3 -95
- package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +64 -0
- package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
- package/Tests/App/Dashboard/InvestigationArtifacts.test.tsx +315 -0
- package/Tests/App/Dashboard/InvestigationDrawer.test.tsx +258 -0
- package/Tests/App/Dashboard/MetricChartsCompare.test.tsx +171 -0
- package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
- package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
- package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
- package/Tests/Server/Utils/Attribution.test.ts +4 -5
- package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
- package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
- package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
- package/Tests/UI/Components/Charts/TooltipEntries.test.ts +44 -8
- package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
- package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
- package/Types/Marketing/Attribution.ts +9 -15
- package/Types/Marketing/MarketingEvent.ts +10 -46
- package/UI/Components/Charts/Area/AreaChart.tsx +15 -0
- package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
- package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +189 -93
- package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +6 -1
- package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +213 -114
- package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +42 -27
- package/UI/Components/Charts/Line/LineChart.tsx +15 -0
- package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
- package/Utils/Telemetry/CrossSignalScope.ts +19 -10
- package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
- package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
- package/build/dist/Models/AnalyticsModels/Index.js +2 -0
- package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +1 -7
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +0 -2
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/ChangeEventService.js +9 -0
- package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
- package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
- package/build/dist/Server/Services/Index.js +2 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Utils/Attribution.js +6 -6
- package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
- package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
- package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
- package/build/dist/Types/Marketing/Attribution.js +9 -15
- package/build/dist/Types/Marketing/Attribution.js.map +1 -1
- package/build/dist/Types/Marketing/MarketingEvent.js +2 -33
- package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +92 -38
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +8 -8
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +95 -37
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +39 -24
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
- package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
- package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
- package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
- package/package.json +1 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js.map +0 -1
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import Dictionary from "../../../Types/Dictionary";
|
|
2
|
+
import OneUptimeDate from "../../../Types/Date";
|
|
3
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
4
|
+
import { JSONArray, JSONObject } from "../../../Types/JSON";
|
|
5
|
+
/*
|
|
6
|
+
* Type-only: pulling the ingest service's runtime graph into this pure
|
|
7
|
+
* module would drag ClickHouse/DB deps into every test that touches it.
|
|
8
|
+
*/
|
|
9
|
+
import type { TelemetryServiceMetadata } from "../../Services/OpenTelemetryIngestService";
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* Change-event ingest parsing + ClickHouse row building. Pure on purpose
|
|
13
|
+
* (no network, no request objects) so App/Tests can pin every validation
|
|
14
|
+
* rule without a server.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
* Events whose source timestamp is outside this window are stamped with
|
|
19
|
+
* the ingestion time instead (original preserved in attributes). The
|
|
20
|
+
* table partitions by toYYYYMMDD(time), so a forged or garbage timestamp
|
|
21
|
+
* must not be allowed to create arbitrary partitions.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_CHANGE_EVENT_AGE_IN_DAYS: number = 366;
|
|
24
|
+
export const MAX_CHANGE_EVENT_FUTURE_SKEW_IN_MINUTES: number = 60;
|
|
25
|
+
|
|
26
|
+
// Caps keep a CI script's mistake from becoming a storage problem.
|
|
27
|
+
export const MAX_CHANGE_EVENTS_PER_REQUEST: number = 100;
|
|
28
|
+
export const MAX_CHANGE_EVENT_TITLE_LENGTH: number = 500;
|
|
29
|
+
export const MAX_CHANGE_EVENT_DESCRIPTION_LENGTH: number = 5000;
|
|
30
|
+
export const MAX_CHANGE_EVENT_TYPE_LENGTH: number = 50;
|
|
31
|
+
export const MAX_CHANGE_EVENT_ATTRIBUTES: number = 50;
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_CHANGE_EVENT_TYPE: string = "deployment";
|
|
34
|
+
|
|
35
|
+
/*
|
|
36
|
+
* Serviceless change events (project-wide markers) have no service
|
|
37
|
+
* retention ladder to consult; deploy markers are tiny and valuable for
|
|
38
|
+
* long-baseline comparisons, so keep them a year.
|
|
39
|
+
*/
|
|
40
|
+
export const DEFAULT_CHANGE_EVENT_RETENTION_IN_DAYS: number = 365;
|
|
41
|
+
|
|
42
|
+
export interface ParsedChangeEventEntry {
|
|
43
|
+
/** null → stamp with the ingestion time. */
|
|
44
|
+
time: Date | null;
|
|
45
|
+
eventType: string;
|
|
46
|
+
title: string;
|
|
47
|
+
description: string;
|
|
48
|
+
attributes: Dictionary<string>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseEntryTime(value: unknown): Date | null {
|
|
52
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
53
|
+
/*
|
|
54
|
+
* Epoch seconds vs milliseconds: anything below 1e12 read as ms would
|
|
55
|
+
* be before 2001 — CI clocks don't say that; treat it as seconds.
|
|
56
|
+
*/
|
|
57
|
+
const ms: number = value < 1e12 ? value * 1000 : value;
|
|
58
|
+
const date: Date = new Date(ms);
|
|
59
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
60
|
+
}
|
|
61
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
62
|
+
const date: Date = new Date(value);
|
|
63
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Validate one raw ingest entry. Returns null when the entry cannot be a
|
|
70
|
+
* change event at all (no usable title); every other malformation is
|
|
71
|
+
* repaired (defaults, trims, caps) rather than rejected — CI pipelines
|
|
72
|
+
* should not fail a deploy over a long description.
|
|
73
|
+
*/
|
|
74
|
+
export function parseChangeEventIngestEntry(
|
|
75
|
+
entry: JSONObject,
|
|
76
|
+
): ParsedChangeEventEntry | null {
|
|
77
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const rawTitle: unknown = entry["title"] ?? entry["name"];
|
|
82
|
+
const title: string =
|
|
83
|
+
typeof rawTitle === "string"
|
|
84
|
+
? rawTitle.trim().slice(0, MAX_CHANGE_EVENT_TITLE_LENGTH)
|
|
85
|
+
: "";
|
|
86
|
+
|
|
87
|
+
if (title === "") {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const rawEventType: unknown = entry["eventType"] ?? entry["type"];
|
|
92
|
+
const eventType: string =
|
|
93
|
+
typeof rawEventType === "string" && rawEventType.trim() !== ""
|
|
94
|
+
? rawEventType.trim().toLowerCase().slice(0, MAX_CHANGE_EVENT_TYPE_LENGTH)
|
|
95
|
+
: DEFAULT_CHANGE_EVENT_TYPE;
|
|
96
|
+
|
|
97
|
+
const rawDescription: unknown = entry["description"];
|
|
98
|
+
const description: string =
|
|
99
|
+
typeof rawDescription === "string"
|
|
100
|
+
? rawDescription.trim().slice(0, MAX_CHANGE_EVENT_DESCRIPTION_LENGTH)
|
|
101
|
+
: "";
|
|
102
|
+
|
|
103
|
+
const attributes: Dictionary<string> = {};
|
|
104
|
+
const rawAttributes: unknown = entry["attributes"];
|
|
105
|
+
if (
|
|
106
|
+
rawAttributes &&
|
|
107
|
+
typeof rawAttributes === "object" &&
|
|
108
|
+
!Array.isArray(rawAttributes)
|
|
109
|
+
) {
|
|
110
|
+
for (const key of Object.keys(rawAttributes as JSONObject)) {
|
|
111
|
+
if (key.trim() === "") {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (Object.keys(attributes).length >= MAX_CHANGE_EVENT_ATTRIBUTES) {
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
const value: unknown = (rawAttributes as JSONObject)[key];
|
|
118
|
+
if (
|
|
119
|
+
typeof value === "string" ||
|
|
120
|
+
typeof value === "number" ||
|
|
121
|
+
typeof value === "boolean"
|
|
122
|
+
) {
|
|
123
|
+
attributes[key] = String(value);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
time: parseEntryTime(entry["time"] ?? entry["timestamp"]),
|
|
130
|
+
eventType,
|
|
131
|
+
title,
|
|
132
|
+
description,
|
|
133
|
+
attributes,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Accept the shapes CI scripts actually send: a bare object (one event),
|
|
139
|
+
* a bare array, or { events: [...] }.
|
|
140
|
+
*/
|
|
141
|
+
export function extractChangeEventEntries(body: unknown): Array<JSONObject> {
|
|
142
|
+
if (!body) {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (Array.isArray(body)) {
|
|
147
|
+
return (body as JSONArray).filter((item: unknown): boolean => {
|
|
148
|
+
return typeof item === "object" && item !== null && !Array.isArray(item);
|
|
149
|
+
}) as Array<JSONObject>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (typeof body !== "object") {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const asObject: JSONObject = body as JSONObject;
|
|
157
|
+
const nested: unknown = asObject["events"];
|
|
158
|
+
if (Array.isArray(nested)) {
|
|
159
|
+
return extractChangeEventEntries(nested);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A single bare event object — but not the {events: <non-array>} shape.
|
|
163
|
+
if (nested === undefined && Object.keys(asObject).length > 0) {
|
|
164
|
+
return [asObject];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* ParsedChangeEventEntry -> ChangeEvent ClickHouse row.
|
|
172
|
+
*/
|
|
173
|
+
export function buildChangeEventDbRow(data: {
|
|
174
|
+
parsed: ParsedChangeEventEntry;
|
|
175
|
+
projectId: ObjectID;
|
|
176
|
+
serviceMetadata: TelemetryServiceMetadata | null;
|
|
177
|
+
retentionDays: number;
|
|
178
|
+
}): JSONObject {
|
|
179
|
+
const { parsed, projectId, serviceMetadata, retentionDays } = data;
|
|
180
|
+
|
|
181
|
+
const ingestionDate: Date = OneUptimeDate.getCurrentDate();
|
|
182
|
+
|
|
183
|
+
let eventTime: Date = parsed.time || ingestionDate;
|
|
184
|
+
let attributes: Dictionary<string> = { ...parsed.attributes };
|
|
185
|
+
|
|
186
|
+
const ageInDays: number = OneUptimeDate.getNumberOfDaysBetweenDates(
|
|
187
|
+
eventTime,
|
|
188
|
+
ingestionDate,
|
|
189
|
+
);
|
|
190
|
+
const futureSkewInMinutes: number =
|
|
191
|
+
(eventTime.getTime() - ingestionDate.getTime()) / (60 * 1000);
|
|
192
|
+
|
|
193
|
+
if (
|
|
194
|
+
Number.isNaN(eventTime.getTime()) ||
|
|
195
|
+
ageInDays > MAX_CHANGE_EVENT_AGE_IN_DAYS ||
|
|
196
|
+
futureSkewInMinutes > MAX_CHANGE_EVENT_FUTURE_SKEW_IN_MINUTES
|
|
197
|
+
) {
|
|
198
|
+
attributes = {
|
|
199
|
+
...attributes,
|
|
200
|
+
"oneuptime.original_time": String(parsed.time),
|
|
201
|
+
};
|
|
202
|
+
eventTime = ingestionDate;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const retentionDate: Date = OneUptimeDate.addRemoveDays(
|
|
206
|
+
ingestionDate,
|
|
207
|
+
retentionDays,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
_id: ObjectID.generateTimeOrdered().toString(),
|
|
212
|
+
createdAt: OneUptimeDate.toClickhouseDateTime(ingestionDate),
|
|
213
|
+
projectId: projectId.toString(),
|
|
214
|
+
...(serviceMetadata
|
|
215
|
+
? {
|
|
216
|
+
primaryEntityId: serviceMetadata.primaryEntityId.toString(),
|
|
217
|
+
primaryEntityType: serviceMetadata.primaryEntityType,
|
|
218
|
+
}
|
|
219
|
+
: {}),
|
|
220
|
+
time: OneUptimeDate.toClickhouseDateTime64(eventTime),
|
|
221
|
+
eventType: parsed.eventType,
|
|
222
|
+
title: parsed.title,
|
|
223
|
+
description: parsed.description,
|
|
224
|
+
attributes,
|
|
225
|
+
attributeKeys: Object.keys(attributes).sort(),
|
|
226
|
+
retentionDate: OneUptimeDate.toClickhouseDateTime(retentionDate),
|
|
227
|
+
} satisfies JSONObject;
|
|
228
|
+
}
|
|
@@ -28,9 +28,7 @@
|
|
|
28
28
|
the home/-prefixed page view and CTA listeners. Those are Home's alone and
|
|
29
29
|
stay in head-basic.ejs. Reporting on a captured touch is handed back to
|
|
30
30
|
the page through window.oneUptimeOnAttributionCaptured for the same
|
|
31
|
-
reason.
|
|
32
|
-
the shared attribution store, and splitting it from the store it reads
|
|
33
|
-
would be worse than sharing an unused function definition.
|
|
31
|
+
reason.
|
|
34
32
|
-%>
|
|
35
33
|
<!--
|
|
36
34
|
Consent.
|
|
@@ -168,9 +166,8 @@
|
|
|
168
166
|
|
|
169
167
|
<!--
|
|
170
168
|
Attribution capture (UTM parameters + ad platform click IDs), and the
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
embed's metadata).
|
|
169
|
+
reader that hands it on to the signup form (through localStorage, same
|
|
170
|
+
origin).
|
|
174
171
|
|
|
175
172
|
STORAGE MODEL
|
|
176
173
|
|
|
@@ -211,9 +208,6 @@
|
|
|
211
208
|
// Mirrors MaxAttributionValueLength on the server.
|
|
212
209
|
var MAX_VALUE_LENGTH = 500;
|
|
213
210
|
|
|
214
|
-
// Mirrors MAX_FIRST_TOUCH_LENGTH in App/API/CalWebhook.ts.
|
|
215
|
-
var MAX_FIRST_TOUCH_LENGTH = 4000;
|
|
216
|
-
|
|
217
211
|
// Attribution seen on this page but not yet allowed to be stored.
|
|
218
212
|
var pending = null;
|
|
219
213
|
|
|
@@ -384,92 +378,6 @@
|
|
|
384
378
|
};
|
|
385
379
|
};
|
|
386
380
|
|
|
387
|
-
/*
|
|
388
|
-
* The same attribution, flattened into the scalar string map Cal.com
|
|
389
|
-
* accepts as booking metadata.
|
|
390
|
-
*
|
|
391
|
-
* Keys are the snake_case wire spelling the server parses in
|
|
392
|
-
* App/API/CalWebhook.ts. The first touch is a nested object, and Cal
|
|
393
|
-
* metadata values are scalars, so it travels as one JSON string under
|
|
394
|
-
* ou_first_touch — which is exactly what the webhook expects to parse.
|
|
395
|
-
*
|
|
396
|
-
* Returns {} when there is nothing to attribute, so a caller can spread
|
|
397
|
-
* it unconditionally without sending an empty metadata bag.
|
|
398
|
-
*/
|
|
399
|
-
/*
|
|
400
|
-
* `bookingKind` says which conversation this embed books. All three
|
|
401
|
-
* embeds use the same Cal event type, so without it the webhook cannot
|
|
402
|
-
* tell a support call from an enterprise demo. It travels in the same
|
|
403
|
-
* bracketed metadata bag as the attribution and is read back by
|
|
404
|
-
* App/API/CalWebhook.ts, which allowlists the value.
|
|
405
|
-
*/
|
|
406
|
-
window.oneUptimeCalAttributionMetadata = function (bookingKind) {
|
|
407
|
-
var attribution = window.oneUptimeGetAttribution();
|
|
408
|
-
var config = {};
|
|
409
|
-
|
|
410
|
-
/*
|
|
411
|
-
* Cal takes booking metadata as FLAT, BRACKETED keys on the embed
|
|
412
|
-
* config — `metadata[utm_source]` — and hands them back on the
|
|
413
|
-
* webhook as payload.metadata.utm_source.
|
|
414
|
-
*
|
|
415
|
-
* Not as a nested `metadata: { utm_source: ... }` object. Cal
|
|
416
|
-
* serialises each config value into a query parameter, so a nested
|
|
417
|
-
* object becomes the string "[object Object]" and every key inside
|
|
418
|
-
* it is lost. Nothing errors; bookings keep being recorded, they
|
|
419
|
-
* just arrive attributable to nothing — which is precisely the bug
|
|
420
|
-
* this whole path exists to fix, so getting the shape wrong here
|
|
421
|
-
* would have been indistinguishable from not having done the work.
|
|
422
|
-
*
|
|
423
|
-
* https://cal.com/help/embedding/prefill-booking-form-embed
|
|
424
|
-
*/
|
|
425
|
-
function put(key, value, maxLength) {
|
|
426
|
-
if (value) {
|
|
427
|
-
config['metadata[' + key + ']'] =
|
|
428
|
-
String(value).slice(0, maxLength || MAX_VALUE_LENGTH);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
Object.keys(UTM_KEY_MAP).forEach(function (param) {
|
|
433
|
-
put(param, attribution.utm[UTM_KEY_MAP[param]]);
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
put('utm_url', attribution.utm.utmUrl);
|
|
437
|
-
|
|
438
|
-
Object.keys(attribution.clickIds).forEach(function (param) {
|
|
439
|
-
put(param, attribution.clickIds[param]);
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
if (attribution.firstTouch) {
|
|
443
|
-
var serialized = JSON.stringify(attribution.firstTouch);
|
|
444
|
-
/*
|
|
445
|
-
* The first touch is JSON, so it gets the JSON bound, NOT the
|
|
446
|
-
* 500-char per-value one. A realistic first touch — five UTM
|
|
447
|
-
* values, a click id, and a landing URL that repeats them all
|
|
448
|
-
* as query parameters — runs past 500 characters easily, and
|
|
449
|
-
* truncating JSON produces a string the webhook's JSON.parse
|
|
450
|
-
* rejects. It would drop the whole first touch, silently.
|
|
451
|
-
*
|
|
452
|
-
* Mirrors MAX_FIRST_TOUCH_LENGTH in App/API/CalWebhook.ts.
|
|
453
|
-
*/
|
|
454
|
-
if (serialized.length <= MAX_FIRST_TOUCH_LENGTH) {
|
|
455
|
-
put('ou_first_touch', serialized, MAX_FIRST_TOUCH_LENGTH);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/*
|
|
460
|
-
* Unconditional, unlike everything above it. The attribution keys
|
|
461
|
-
* describe the visitor and are absent when there is nothing to say
|
|
462
|
-
* about them; the booking kind describes the embed, which always
|
|
463
|
-
* knows what it is. A booking from a visitor who refused
|
|
464
|
-
* attribution storage still arrives knowing it was a demo.
|
|
465
|
-
*/
|
|
466
|
-
if (bookingKind) {
|
|
467
|
-
config['metadata[ou_booking_kind]'] = String(bookingKind).slice(0, MAX_VALUE_LENGTH);
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
return config;
|
|
471
|
-
};
|
|
472
|
-
|
|
473
381
|
var touch = readTouchFromUrl();
|
|
474
382
|
|
|
475
383
|
if (touch) {
|
|
@@ -184,6 +184,7 @@ function renderWidget(
|
|
|
184
184
|
|
|
185
185
|
interface CapturedChartProps {
|
|
186
186
|
metricViewData: MetricViewData;
|
|
187
|
+
metricResults?: Array<Record<string, unknown>> | undefined;
|
|
187
188
|
onTimeRangeSelect?: ((startTime: Date, endTime: Date) => void) | undefined;
|
|
188
189
|
enableSeriesActions?: boolean | undefined;
|
|
189
190
|
}
|
|
@@ -288,6 +289,69 @@ describe("dashboard chart widget drag-to-zoom", () => {
|
|
|
288
289
|
expect(lastChartProps().metricViewData.rangeToken).toBe(TimeRange.CUSTOM);
|
|
289
290
|
});
|
|
290
291
|
|
|
292
|
+
test("a stale fetch response can never overwrite a newer window's data", async () => {
|
|
293
|
+
/*
|
|
294
|
+
* Zoom then Reset puts two aggregate calls in flight; if the SLOWER
|
|
295
|
+
* (zoom) response lands last, its data must be discarded — otherwise
|
|
296
|
+
* the chart paints the zoomed window's series under the dashboard
|
|
297
|
+
* window's axis.
|
|
298
|
+
*/
|
|
299
|
+
const pendingFetches: Array<(value: unknown) => void> = [];
|
|
300
|
+
fetchResultsMock.mockImplementation(() => {
|
|
301
|
+
return new Promise((resolve: (value: unknown) => void) => {
|
|
302
|
+
pendingFetches.push(resolve);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
renderWidget();
|
|
307
|
+
|
|
308
|
+
await waitFor(() => {
|
|
309
|
+
expect(pendingFetches.length).toBe(1);
|
|
310
|
+
});
|
|
311
|
+
act(() => {
|
|
312
|
+
pendingFetches[0]?.([{ data: [], truncated: false }]);
|
|
313
|
+
});
|
|
314
|
+
await waitFor(() => {
|
|
315
|
+
expect(screen.getByTestId("metric-charts")).toBeInTheDocument();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
act(() => {
|
|
319
|
+
lastChartProps().onTimeRangeSelect?.(ZOOM_START, ZOOM_END);
|
|
320
|
+
});
|
|
321
|
+
await waitFor(() => {
|
|
322
|
+
expect(pendingFetches.length).toBe(2);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
fireEvent.click(screen.getByText("Reset"));
|
|
326
|
+
await waitFor(() => {
|
|
327
|
+
expect(pendingFetches.length).toBe(3);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const staleZoomResults: Array<Record<string, unknown>> = [
|
|
331
|
+
{ data: [{ marker: "stale-zoom" }], truncated: false },
|
|
332
|
+
];
|
|
333
|
+
const freshResetResults: Array<Record<string, unknown>> = [
|
|
334
|
+
{ data: [], truncated: false },
|
|
335
|
+
];
|
|
336
|
+
|
|
337
|
+
// The newer (reset) fetch answers first…
|
|
338
|
+
act(() => {
|
|
339
|
+
pendingFetches[2]?.(freshResetResults);
|
|
340
|
+
});
|
|
341
|
+
await waitFor(() => {
|
|
342
|
+
expect(lastChartProps().metricResults).toBe(freshResetResults);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// …then the stale zoom fetch answers last and must be ignored.
|
|
346
|
+
act(() => {
|
|
347
|
+
pendingFetches[1]?.(staleZoomResults);
|
|
348
|
+
});
|
|
349
|
+
await act(async () => {
|
|
350
|
+
await Promise.resolve();
|
|
351
|
+
});
|
|
352
|
+
expect(lastChartProps().metricResults).toBe(freshResetResults);
|
|
353
|
+
});
|
|
354
|
+
|
|
291
355
|
test("edit mode disables zoom and series navigation", async () => {
|
|
292
356
|
renderWidget({ isEditMode: true });
|
|
293
357
|
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import {
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
jest,
|
|
8
|
+
test,
|
|
9
|
+
} from "@jest/globals";
|
|
10
|
+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
11
|
+
import * as React from "react";
|
|
12
|
+
import getJestMockFunction, { MockFunction } from "../../MockType";
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* The shared event-overlay hook is what puts "what happened here" on every
|
|
16
|
+
* metric chart: incidents and alerts as solid severity-colored markers
|
|
17
|
+
* with click-through, change events (deploys) as dashed indigo markers.
|
|
18
|
+
* It must fetch nothing on public dashboards and survive any single
|
|
19
|
+
* source failing.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const getListMock: MockFunction = getJestMockFunction();
|
|
23
|
+
const analyticsGetListMock: MockFunction = getJestMockFunction();
|
|
24
|
+
const isPublicDashboardMock: MockFunction = getJestMockFunction();
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
* The arrow wrappers are load bearing: jest.mock is hoisted above the
|
|
28
|
+
* compiled requires, so the mock variables above are still unassigned when
|
|
29
|
+
* the factory runs.
|
|
30
|
+
*/
|
|
31
|
+
jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
|
|
32
|
+
return {
|
|
33
|
+
__esModule: true,
|
|
34
|
+
default: {
|
|
35
|
+
getList: (...args: Array<any>) => {
|
|
36
|
+
return getListMock(...args);
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
jest.mock("../../../UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI", () => {
|
|
43
|
+
return {
|
|
44
|
+
__esModule: true,
|
|
45
|
+
default: {
|
|
46
|
+
getList: (...args: Array<any>) => {
|
|
47
|
+
return analyticsGetListMock(...args);
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
jest.mock(
|
|
54
|
+
"../../../../App/FeatureSet/Dashboard/src/Components/Dashboard/Utils/PublicDashboardContext",
|
|
55
|
+
() => {
|
|
56
|
+
return {
|
|
57
|
+
__esModule: true,
|
|
58
|
+
isPublicDashboard: (...args: Array<any>) => {
|
|
59
|
+
return isPublicDashboardMock(...args);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
import useEventTimeReferenceLines, {
|
|
66
|
+
EventTimeReferenceLines,
|
|
67
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/Utils/UseEventTimeReferenceLines";
|
|
68
|
+
import ChartTimeReferenceLineProps from "../../../UI/Components/Charts/Types/TimeReferenceLineProps";
|
|
69
|
+
import InBetween from "../../../Types/BaseDatabase/InBetween";
|
|
70
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
71
|
+
import ProjectUtil from "../../../UI/Utils/Project";
|
|
72
|
+
|
|
73
|
+
const WINDOW: InBetween<Date> = new InBetween<Date>(
|
|
74
|
+
new Date("2026-08-20T10:00:00.000Z"),
|
|
75
|
+
new Date("2026-08-20T11:00:00.000Z"),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const PROJECT_ID: ObjectID = new ObjectID(
|
|
79
|
+
"9e1b6b0e-0000-4000-8000-000000000011",
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
function Probe(props: {
|
|
83
|
+
enabled: boolean;
|
|
84
|
+
window: InBetween<Date> | null;
|
|
85
|
+
}): React.ReactElement {
|
|
86
|
+
const { lines, markerCount }: EventTimeReferenceLines =
|
|
87
|
+
useEventTimeReferenceLines({
|
|
88
|
+
enabled: props.enabled,
|
|
89
|
+
window: props.window,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div>
|
|
94
|
+
<span data-testid="marker-count">{markerCount}</span>
|
|
95
|
+
{lines.map((line: ChartTimeReferenceLineProps, index: number) => {
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
key={index}
|
|
99
|
+
data-testid="marker"
|
|
100
|
+
data-color={line.color}
|
|
101
|
+
data-dash={line.strokeDasharray || "solid"}
|
|
102
|
+
data-clickable={line.onClick ? "yes" : "no"}
|
|
103
|
+
>
|
|
104
|
+
{line.label}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
})}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
getListMock.mockReset();
|
|
114
|
+
analyticsGetListMock.mockReset();
|
|
115
|
+
isPublicDashboardMock.mockReset();
|
|
116
|
+
isPublicDashboardMock.mockReturnValue(false);
|
|
117
|
+
jest.spyOn(ProjectUtil, "getCurrentProjectId").mockReturnValue(PROJECT_ID);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
afterEach(() => {
|
|
121
|
+
jest.restoreAllMocks();
|
|
122
|
+
cleanup();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("useEventTimeReferenceLines", () => {
|
|
126
|
+
test("maps incidents, alerts, and change events onto distinct marker styles", async () => {
|
|
127
|
+
getListMock.mockImplementation((args: { modelType: { name: string } }) => {
|
|
128
|
+
if (args.modelType.name === "Incident") {
|
|
129
|
+
return Promise.resolve({
|
|
130
|
+
data: [
|
|
131
|
+
{
|
|
132
|
+
id: new ObjectID("11111111-0000-4000-8000-000000000001"),
|
|
133
|
+
createdAt: "2026-08-20T10:10:00.000Z",
|
|
134
|
+
title: "API is down",
|
|
135
|
+
incidentSeverity: { color: "#123456" },
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return Promise.resolve({
|
|
141
|
+
data: [
|
|
142
|
+
{
|
|
143
|
+
id: new ObjectID("22222222-0000-4000-8000-000000000002"),
|
|
144
|
+
createdAt: "2026-08-20T10:20:00.000Z",
|
|
145
|
+
title: "High latency",
|
|
146
|
+
alertSeverity: undefined,
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
analyticsGetListMock.mockResolvedValue({
|
|
152
|
+
data: [
|
|
153
|
+
{
|
|
154
|
+
time: "2026-08-20T10:03:00.000Z",
|
|
155
|
+
title: "v2.31.0",
|
|
156
|
+
eventType: "deployment",
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
render(<Probe enabled={true} window={WINDOW} />);
|
|
162
|
+
|
|
163
|
+
await waitFor(() => {
|
|
164
|
+
expect(screen.getByTestId("marker-count").textContent).toBe("3");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const markers: Array<HTMLElement> = screen.getAllByTestId("marker");
|
|
168
|
+
const byLabel: (needle: string) => HTMLElement = (
|
|
169
|
+
needle: string,
|
|
170
|
+
): HTMLElement => {
|
|
171
|
+
const found: HTMLElement | undefined = markers.find(
|
|
172
|
+
(marker: HTMLElement) => {
|
|
173
|
+
return (marker.textContent || "").includes(needle);
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
if (!found) {
|
|
177
|
+
throw new Error(`no marker containing ${needle}`);
|
|
178
|
+
}
|
|
179
|
+
return found;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const incident: HTMLElement = byLabel("Incident: API is down");
|
|
183
|
+
expect(incident.dataset["color"]).toBe("#123456");
|
|
184
|
+
expect(incident.dataset["dash"]).toBe("solid");
|
|
185
|
+
expect(incident.dataset["clickable"]).toBe("yes");
|
|
186
|
+
|
|
187
|
+
// Alert without a severity color falls back to amber.
|
|
188
|
+
expect(byLabel("Alert: High latency").dataset["color"]).toBe("#fbbf24");
|
|
189
|
+
|
|
190
|
+
const deploy: HTMLElement = byLabel("Deploy: v2.31.0");
|
|
191
|
+
expect(deploy.dataset["color"]).toBe("#6366f1");
|
|
192
|
+
expect(deploy.dataset["dash"]).toBe("4 4");
|
|
193
|
+
// Change events have no detail page — no dead-end click handler.
|
|
194
|
+
expect(deploy.dataset["clickable"]).toBe("no");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("one source failing never takes the others down", async () => {
|
|
198
|
+
getListMock.mockImplementation((args: { modelType: { name: string } }) => {
|
|
199
|
+
if (args.modelType.name === "Incident") {
|
|
200
|
+
return Promise.resolve({
|
|
201
|
+
data: [
|
|
202
|
+
{
|
|
203
|
+
id: new ObjectID("11111111-0000-4000-8000-000000000001"),
|
|
204
|
+
createdAt: "2026-08-20T10:10:00.000Z",
|
|
205
|
+
title: "Still here",
|
|
206
|
+
incidentSeverity: undefined,
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return Promise.resolve({ data: [] });
|
|
212
|
+
});
|
|
213
|
+
analyticsGetListMock.mockRejectedValue(new Error("clickhouse hiccup"));
|
|
214
|
+
|
|
215
|
+
render(<Probe enabled={true} window={WINDOW} />);
|
|
216
|
+
|
|
217
|
+
await waitFor(() => {
|
|
218
|
+
expect(screen.getByTestId("marker-count").textContent).toBe("1");
|
|
219
|
+
});
|
|
220
|
+
expect(screen.getByText(/Incident: Still here/)).toBeInTheDocument();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("public dashboards fetch nothing at all", async () => {
|
|
224
|
+
isPublicDashboardMock.mockReturnValue(true);
|
|
225
|
+
|
|
226
|
+
render(<Probe enabled={true} window={WINDOW} />);
|
|
227
|
+
|
|
228
|
+
// Give any (wrong) fetch a chance to fire.
|
|
229
|
+
await new Promise((resolve: (value: unknown) => void) => {
|
|
230
|
+
setTimeout(resolve, 20);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(getListMock).not.toHaveBeenCalled();
|
|
234
|
+
expect(analyticsGetListMock).not.toHaveBeenCalled();
|
|
235
|
+
expect(screen.getByTestId("marker-count").textContent).toBe("0");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("disabled and windowless states fetch nothing and yield no lines", async () => {
|
|
239
|
+
render(<Probe enabled={false} window={WINDOW} />);
|
|
240
|
+
render(<Probe enabled={true} window={null} />);
|
|
241
|
+
|
|
242
|
+
await new Promise((resolve: (value: unknown) => void) => {
|
|
243
|
+
setTimeout(resolve, 20);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(getListMock).not.toHaveBeenCalled();
|
|
247
|
+
expect(analyticsGetListMock).not.toHaveBeenCalled();
|
|
248
|
+
});
|
|
249
|
+
});
|