@oneuptime/common 12.0.19 → 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/API/EnterpriseLicenseAPI.ts +94 -19
- package/Server/EnvironmentConfig.ts +15 -5
- 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/Services/ProjectService.ts +104 -0
- package/Server/Utils/Attribution.ts +11 -11
- package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
- package/Server/Utils/Response.ts +2 -2
- package/Server/Utils/StartServer.ts +2 -2
- package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
- package/Server/Views/Partials/AnalyticsConsent.ejs +441 -0
- package/Tests/App/Dashboard/AlertEpisodeViewFields.test.tsx +465 -0
- package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +366 -0
- package/Tests/App/Dashboard/DiscoveryScanWizardValidation.test.tsx +408 -0
- package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
- package/Tests/App/Dashboard/IncidentEpisodeViewFields.test.tsx +474 -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/MetricSeriesInvestigateMenu.test.tsx +368 -0
- package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
- package/Tests/Server/API/EnterpriseLicenseReportUserCount.test.ts +763 -0
- package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
- package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +138 -0
- package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +516 -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/Server/Utils/ResponseRenderAnalytics.test.ts +53 -0
- package/Tests/Server/Views/AnalyticsConsentPartial.test.ts +241 -0
- package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
- package/Tests/UI/Components/Charts/ChartTooltipSorted.test.tsx +129 -0
- package/Tests/UI/Components/Charts/TooltipEntries.test.ts +191 -0
- package/Tests/UI/Components/Detail/DetailFieldErrors.test.tsx +347 -0
- package/Tests/UI/Components/Forms/BasicFormStepValidation.test.tsx +392 -0
- package/Tests/UI/Components/Forms/ValidationFormSteps.test.ts +549 -0
- package/Tests/Utils/EnterpriseLicenseSync.test.ts +562 -0
- package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
- package/Types/Analytics/RevenueEvent.ts +1 -0
- package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
- package/Types/Marketing/Attribution.ts +9 -15
- package/Types/Marketing/MarketingEvent.ts +19 -12
- package/UI/Components/Charts/Area/AreaChart.tsx +23 -1
- package/UI/Components/Charts/Bar/BarChart.tsx +8 -1
- package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
- package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +208 -97
- package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +26 -3
- package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +232 -118
- package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +120 -0
- package/UI/Components/Charts/Line/LineChart.tsx +23 -1
- package/UI/Components/Detail/Detail.tsx +18 -1
- package/Utils/EnterpriseLicense/EnterpriseLicenseSync.ts +244 -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/API/EnterpriseLicenseAPI.js +66 -12
- package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +14 -5
- 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/Services/ProjectService.js +88 -0
- package/build/dist/Server/Services/ProjectService.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/Response.js +2 -2
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +2 -2
- package/build/dist/Server/Utils/StartServer.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/Analytics/RevenueEvent.js +1 -0
- package/build/dist/Types/Analytics/RevenueEvent.js.map +1 -1
- 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 +12 -4
- package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js +10 -2
- package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js +9 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.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 +114 -51
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +35 -23
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +122 -55
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +86 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -0
- package/build/dist/UI/Components/Charts/Line/LineChart.js +10 -2
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Detail/Detail.js +17 -1
- package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
- package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js +174 -0
- package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js.map +1 -0
- 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
|
@@ -897,6 +897,98 @@ These are no longer recorded against the project and have to be cancelled by han
|
|
|
897
897
|
);
|
|
898
898
|
}
|
|
899
899
|
|
|
900
|
+
/*
|
|
901
|
+
* The first-subscription conversion.
|
|
902
|
+
*
|
|
903
|
+
* A project created directly on a paid plan never passes through changePlan,
|
|
904
|
+
* so the plan-change pass below never sees it — and could not report it if
|
|
905
|
+
* it did, because direction is decided by plan ORDER and a first plan has no
|
|
906
|
+
* previous tier to compare against. That left the most bid-worthy self-serve
|
|
907
|
+
* conversion there is emitting nothing at all: a visitor could click an ad,
|
|
908
|
+
* sign up, pick Growth and pay, and no channel carried a single event.
|
|
909
|
+
*
|
|
910
|
+
* It is its own event rather than an upgrade with an empty old_plan because
|
|
911
|
+
* this is new business and an upgrade is expansion. Mixing them is a
|
|
912
|
+
* distinction nothing downstream could restore.
|
|
913
|
+
*
|
|
914
|
+
* Free projects reach here too — a project on the free plan is still
|
|
915
|
+
* subscribed at the payment provider — so the paid check is what keeps this
|
|
916
|
+
* a revenue event.
|
|
917
|
+
*/
|
|
918
|
+
private captureSubscriptionStartedAnalytics(data: {
|
|
919
|
+
project: Model;
|
|
920
|
+
plan: SubscriptionPlan;
|
|
921
|
+
planId: string;
|
|
922
|
+
seats: number;
|
|
923
|
+
}): void {
|
|
924
|
+
if (!data.project.createdOwnerEmail) {
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Per-month amount, or null when unknown (custom pricing / unknown plan).
|
|
929
|
+
const monthlyAmountInUSD: number | null = data.plan.isCustomPricing()
|
|
930
|
+
? null
|
|
931
|
+
: data.plan.getYearlyPlanId() === data.planId
|
|
932
|
+
? data.plan.getYearlySubscriptionAmountInUSD()
|
|
933
|
+
: data.plan.getMonthlySubscriptionAmountInUSD();
|
|
934
|
+
|
|
935
|
+
const planIsPaid: boolean =
|
|
936
|
+
data.plan.isCustomPricing() ||
|
|
937
|
+
(monthlyAmountInUSD !== null && monthlyAmountInUSD > 0);
|
|
938
|
+
|
|
939
|
+
if (!planIsPaid) {
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const properties: JSONObject = {
|
|
944
|
+
project_id: data.project.id?.toString() || "",
|
|
945
|
+
new_plan: data.plan.getName(),
|
|
946
|
+
seats: data.seats,
|
|
947
|
+
/*
|
|
948
|
+
* Always true. The project went straight from not existing to paying,
|
|
949
|
+
* which is the acquisition this event exists to report.
|
|
950
|
+
*/
|
|
951
|
+
is_paid_conversion: true,
|
|
952
|
+
has_custom_pricing: data.plan.isCustomPricing(),
|
|
953
|
+
utm_source: data.project.utmSource || "",
|
|
954
|
+
utm_medium: data.project.utmMedium || "",
|
|
955
|
+
utm_campaign: data.project.utmCampaign || "",
|
|
956
|
+
utm_term: data.project.utmTerm || "",
|
|
957
|
+
utm_content: data.project.utmContent || "",
|
|
958
|
+
click_ids: data.project.clickIds || {},
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
if (monthlyAmountInUSD !== null) {
|
|
962
|
+
properties["new_monthly_amount_in_usd"] = monthlyAmountInUSD;
|
|
963
|
+
// Value for ROAS reporting: monthly recurring revenue at the start.
|
|
964
|
+
properties["value"] = monthlyAmountInUSD * data.seats;
|
|
965
|
+
properties["currency"] = "USD";
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
ProductAnalytics.capture({
|
|
969
|
+
event: "server/subscription_started",
|
|
970
|
+
distinctId: data.project.createdOwnerEmail.toString(),
|
|
971
|
+
properties: properties,
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
MarketingEventUtil.emitInBackground(
|
|
975
|
+
MarketingEventUtil.buildEvent({
|
|
976
|
+
eventType: MarketingEventType.SubscriptionStarted,
|
|
977
|
+
/*
|
|
978
|
+
* Naturally unique: a project has exactly one first subscription, so a
|
|
979
|
+
* queue retry carries the identical id and the receiver dedupes it.
|
|
980
|
+
* Unlike a plan change, this needs no timestamp to separate it from
|
|
981
|
+
* the next one — there is no next one.
|
|
982
|
+
*/
|
|
983
|
+
eventId: `${MarketingEventType.SubscriptionStarted}:${data.project.id?.toString()}`,
|
|
984
|
+
occurredAt: data.project.createdAt || new Date(),
|
|
985
|
+
email: data.project.createdOwnerEmail.toString(),
|
|
986
|
+
attributionSource: data.project,
|
|
987
|
+
data: properties,
|
|
988
|
+
}),
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
|
|
900
992
|
/*
|
|
901
993
|
* The paid-conversion event for ad platforms: fires server-side (immune to
|
|
902
994
|
* ad blockers) whenever a project's subscription plan changes, with enough
|
|
@@ -1286,6 +1378,18 @@ These are no longer recorded against the project and have to be cancelled by han
|
|
|
1286
1378
|
},
|
|
1287
1379
|
});
|
|
1288
1380
|
|
|
1381
|
+
/*
|
|
1382
|
+
* Raised after the row is written, mirroring changePlan: the write is
|
|
1383
|
+
* what makes the subscription real to OneUptime, and the conversion
|
|
1384
|
+
* describes a subscription that exists.
|
|
1385
|
+
*/
|
|
1386
|
+
this.captureSubscriptionStartedAnalytics({
|
|
1387
|
+
project: createdItem,
|
|
1388
|
+
plan: plan,
|
|
1389
|
+
planId: createdItem.paymentProviderPlanId!,
|
|
1390
|
+
seats: 1,
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1289
1393
|
// mark the promo code as used it it exists.
|
|
1290
1394
|
|
|
1291
1395
|
if (createdItem.paymentProviderPromoCode) {
|
|
@@ -11,12 +11,12 @@ import { JSONObject, JSONValue } from "../../Types/JSON";
|
|
|
11
11
|
|
|
12
12
|
/*
|
|
13
13
|
* Sanitizers for attribution submitted by unauthenticated callers — the signup
|
|
14
|
-
* form
|
|
14
|
+
* form and the enterprise licence request form.
|
|
15
15
|
*
|
|
16
|
-
* Everything here is a whitelist, never a denylist.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* Everything here is a whitelist, never a denylist. Form bodies are free-form
|
|
17
|
+
* caller content (names, notes, free-text answers), and only the keys named in
|
|
18
|
+
* Common/Types/Marketing/Attribution.ts may be copied into a row that is later
|
|
19
|
+
* forwarded to an ad platform.
|
|
20
20
|
*
|
|
21
21
|
* This file also owns email normalisation and hashing. Meta and Reddit each
|
|
22
22
|
* used to carry a private copy of the hash, and MarketingConversion.emailHash
|
|
@@ -101,10 +101,10 @@ export default class Attribution {
|
|
|
101
101
|
/*
|
|
102
102
|
* UTM values out of an arbitrary object, accepting either spelling.
|
|
103
103
|
*
|
|
104
|
-
* A URL
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
104
|
+
* A URL carries `utm_source`; a JSON body posted by the signup page carries
|
|
105
|
+
* `utmSource`. Both arrive at server doors that write the same columns, so
|
|
106
|
+
* both are read here rather than making each caller remember which shape it
|
|
107
|
+
* is holding. camelCase wins when a caller
|
|
108
108
|
* somehow sends both, because that is the spelling the browser writes
|
|
109
109
|
* deliberately and the snake_case one is the raw URL echo.
|
|
110
110
|
*/
|
|
@@ -180,8 +180,8 @@ export default class Attribution {
|
|
|
180
180
|
|
|
181
181
|
/*
|
|
182
182
|
* SHA-256 of the normalised email, hex encoded — the identifier every ad
|
|
183
|
-
* platform's enhanced matching expects, and the key
|
|
184
|
-
*
|
|
183
|
+
* platform's enhanced matching expects, and the key a receiver joins one
|
|
184
|
+
* person's conversions on.
|
|
185
185
|
*/
|
|
186
186
|
public static hashEmail(email: string | undefined): string | null {
|
|
187
187
|
const normalized: string | null = this.normalizeEmail(email);
|
|
@@ -12,8 +12,8 @@ const REQUEST_TIMEOUT_MS: number = 15000;
|
|
|
12
12
|
/*
|
|
13
13
|
* Delivers marketing conversion events to one operator-configured endpoint.
|
|
14
14
|
*
|
|
15
|
-
* OneUptime keeps no conversion ledger, so this is the only exit for a signup
|
|
16
|
-
*
|
|
15
|
+
* OneUptime keeps no conversion ledger, so this is the only exit for a signup
|
|
16
|
+
* or a plan change. That has one consequence worth stating
|
|
17
17
|
* plainly: a delivery this class gives up on is gone, because there is no row
|
|
18
18
|
* anywhere to reconcile against later. deliver() therefore THROWS on anything
|
|
19
19
|
* that might succeed on a retry, and the queue is what retries it.
|
|
@@ -21,9 +21,8 @@ const REQUEST_TIMEOUT_MS: number = 15000;
|
|
|
21
21
|
* SIGNING
|
|
22
22
|
*
|
|
23
23
|
* The signature is HMAC-SHA256 over the exact request body bytes, hex encoded,
|
|
24
|
-
* in `x-oneuptime-signature-256`.
|
|
25
|
-
*
|
|
26
|
-
* two. The receiver must compute its digest over the raw bytes it received —
|
|
24
|
+
* in `x-oneuptime-signature-256`. The receiver must compute its digest over
|
|
25
|
+
* the raw bytes it received —
|
|
27
26
|
* JSON parsed and re-serialised is NOT equivalent, because whitespace, key
|
|
28
27
|
* order and escaping all change the digest.
|
|
29
28
|
*
|
package/Server/Utils/Response.ts
CHANGED
|
@@ -25,7 +25,7 @@ import MimeType from "../../Types/File/MimeType";
|
|
|
25
25
|
import PositiveNumber from "../../Types/PositiveNumber";
|
|
26
26
|
import Route from "../../Types/API/Route";
|
|
27
27
|
import CaptureSpan from "./Telemetry/CaptureSpan";
|
|
28
|
-
import {
|
|
28
|
+
import { GoogleTagManagerEnabled } from "../EnvironmentConfig";
|
|
29
29
|
|
|
30
30
|
/*
|
|
31
31
|
* Raster image types, which is to say the types that cannot carry script and
|
|
@@ -239,7 +239,7 @@ export default class Response {
|
|
|
239
239
|
* already use; passing the flag explicitly still wins.
|
|
240
240
|
*/
|
|
241
241
|
oneUptimeResponse.render(path, {
|
|
242
|
-
enableGoogleTagManager:
|
|
242
|
+
enableGoogleTagManager: GoogleTagManagerEnabled,
|
|
243
243
|
...vars,
|
|
244
244
|
});
|
|
245
245
|
}
|
|
@@ -3,7 +3,7 @@ import CommonAPI from "../API/Index";
|
|
|
3
3
|
import { StatusAPIOptions } from "../API/StatusAPI";
|
|
4
4
|
import {
|
|
5
5
|
AppVersion,
|
|
6
|
-
|
|
6
|
+
GoogleTagManagerEnabled,
|
|
7
7
|
TrustedProxyHops,
|
|
8
8
|
getFrontendEnvVars,
|
|
9
9
|
} from "../EnvironmentConfig";
|
|
@@ -449,7 +449,7 @@ const init: InitFunction = async (
|
|
|
449
449
|
}
|
|
450
450
|
|
|
451
451
|
return res.render(path.resolve(process.cwd(), "views/index.ejs"), {
|
|
452
|
-
enableGoogleTagManager:
|
|
452
|
+
enableGoogleTagManager: GoogleTagManagerEnabled,
|
|
453
453
|
...variables,
|
|
454
454
|
});
|
|
455
455
|
} catch (err) {
|
|
@@ -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
|
+
}
|