@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.
Files changed (124) hide show
  1. package/Models/AnalyticsModels/ChangeEvent.ts +331 -0
  2. package/Models/AnalyticsModels/Index.ts +2 -0
  3. package/Server/API/EnterpriseLicenseAPI.ts +94 -19
  4. package/Server/EnvironmentConfig.ts +15 -5
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +0 -2
  7. package/Server/Services/ChangeEventService.ts +11 -0
  8. package/Server/Services/EnterpriseLicenseService.ts +3 -3
  9. package/Server/Services/Index.ts +2 -0
  10. package/Server/Services/ProjectService.ts +104 -0
  11. package/Server/Utils/Attribution.ts +11 -11
  12. package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
  13. package/Server/Utils/Response.ts +2 -2
  14. package/Server/Utils/StartServer.ts +2 -2
  15. package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
  16. package/Server/Views/Partials/AnalyticsConsent.ejs +441 -0
  17. package/Tests/App/Dashboard/AlertEpisodeViewFields.test.tsx +465 -0
  18. package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +366 -0
  19. package/Tests/App/Dashboard/DiscoveryScanWizardValidation.test.tsx +408 -0
  20. package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
  21. package/Tests/App/Dashboard/IncidentEpisodeViewFields.test.tsx +474 -0
  22. package/Tests/App/Dashboard/InvestigationArtifacts.test.tsx +315 -0
  23. package/Tests/App/Dashboard/InvestigationDrawer.test.tsx +258 -0
  24. package/Tests/App/Dashboard/MetricChartsCompare.test.tsx +171 -0
  25. package/Tests/App/Dashboard/MetricSeriesInvestigateMenu.test.tsx +368 -0
  26. package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
  27. package/Tests/Server/API/EnterpriseLicenseReportUserCount.test.ts +763 -0
  28. package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
  29. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +138 -0
  30. package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +516 -0
  31. package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
  32. package/Tests/Server/Utils/Attribution.test.ts +4 -5
  33. package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
  34. package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
  35. package/Tests/Server/Utils/ResponseRenderAnalytics.test.ts +53 -0
  36. package/Tests/Server/Views/AnalyticsConsentPartial.test.ts +241 -0
  37. package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
  38. package/Tests/UI/Components/Charts/ChartTooltipSorted.test.tsx +129 -0
  39. package/Tests/UI/Components/Charts/TooltipEntries.test.ts +191 -0
  40. package/Tests/UI/Components/Detail/DetailFieldErrors.test.tsx +347 -0
  41. package/Tests/UI/Components/Forms/BasicFormStepValidation.test.tsx +392 -0
  42. package/Tests/UI/Components/Forms/ValidationFormSteps.test.ts +549 -0
  43. package/Tests/Utils/EnterpriseLicenseSync.test.ts +562 -0
  44. package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
  45. package/Types/Analytics/RevenueEvent.ts +1 -0
  46. package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
  47. package/Types/Marketing/Attribution.ts +9 -15
  48. package/Types/Marketing/MarketingEvent.ts +19 -12
  49. package/UI/Components/Charts/Area/AreaChart.tsx +23 -1
  50. package/UI/Components/Charts/Bar/BarChart.tsx +8 -1
  51. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
  52. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +208 -97
  53. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +26 -3
  54. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +232 -118
  55. package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +120 -0
  56. package/UI/Components/Charts/Line/LineChart.tsx +23 -1
  57. package/UI/Components/Detail/Detail.tsx +18 -1
  58. package/Utils/EnterpriseLicense/EnterpriseLicenseSync.ts +244 -0
  59. package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
  60. package/Utils/Telemetry/CrossSignalScope.ts +19 -10
  61. package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
  62. package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
  63. package/build/dist/Models/AnalyticsModels/Index.js +2 -0
  64. package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
  65. package/build/dist/Server/API/EnterpriseLicenseAPI.js +66 -12
  66. package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
  67. package/build/dist/Server/EnvironmentConfig.js +14 -5
  68. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  69. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
  70. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
  71. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +0 -2
  72. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  73. package/build/dist/Server/Services/ChangeEventService.js +9 -0
  74. package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
  75. package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
  76. package/build/dist/Server/Services/Index.js +2 -0
  77. package/build/dist/Server/Services/Index.js.map +1 -1
  78. package/build/dist/Server/Services/ProjectService.js +88 -0
  79. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  80. package/build/dist/Server/Utils/Attribution.js +6 -6
  81. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
  82. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
  83. package/build/dist/Server/Utils/Response.js +2 -2
  84. package/build/dist/Server/Utils/Response.js.map +1 -1
  85. package/build/dist/Server/Utils/StartServer.js +2 -2
  86. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  87. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
  88. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
  89. package/build/dist/Types/Analytics/RevenueEvent.js +1 -0
  90. package/build/dist/Types/Analytics/RevenueEvent.js.map +1 -1
  91. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
  92. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
  93. package/build/dist/Types/Marketing/Attribution.js +9 -15
  94. package/build/dist/Types/Marketing/Attribution.js.map +1 -1
  95. package/build/dist/Types/Marketing/MarketingEvent.js +12 -4
  96. package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
  97. package/build/dist/UI/Components/Charts/Area/AreaChart.js +10 -2
  98. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  99. package/build/dist/UI/Components/Charts/Bar/BarChart.js +9 -1
  100. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  101. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
  102. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  103. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +114 -51
  104. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  105. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +35 -23
  106. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  107. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +122 -55
  108. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  109. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +86 -0
  110. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -0
  111. package/build/dist/UI/Components/Charts/Line/LineChart.js +10 -2
  112. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  113. package/build/dist/UI/Components/Detail/Detail.js +17 -1
  114. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  115. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js +174 -0
  116. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js.map +1 -0
  117. package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
  118. package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
  119. package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
  120. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
  121. package/package.json +1 -1
  122. package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
  123. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js.map +0 -1
@@ -0,0 +1,331 @@
1
+ import AnalyticsBaseModel from "./AnalyticsBaseModel/AnalyticsBaseModel";
2
+ import Route from "../../Types/API/Route";
3
+ import AnalyticsTableEngine from "../../Types/AnalyticsDatabase/AnalyticsTableEngine";
4
+ import AnalyticsTableName from "../../Types/AnalyticsDatabase/AnalyticsTableName";
5
+ import AnalyticsTableColumn, {
6
+ SkipIndexType,
7
+ } from "../../Types/AnalyticsDatabase/TableColumn";
8
+ import TableColumnType from "../../Types/AnalyticsDatabase/TableColumnType";
9
+ import OperationalResource from "../../Types/Database/AccessControl/OperationalResource";
10
+ import OwnedThrough from "../../Types/Database/AccessControl/OwnedThrough";
11
+ import { JSONObject } from "../../Types/JSON";
12
+ import ObjectID from "../../Types/ObjectID";
13
+ import Permission from "../../Types/Permission";
14
+ import Service from "../DatabaseModels/Service";
15
+ import ServiceType from "../../Types/Telemetry/ServiceType";
16
+
17
+ /*
18
+ * Deploys, config changes, scaling events — the "what changed?" signal.
19
+ * Rows are posted by CI/CD pipelines through the change-events ingest API
20
+ * (or created from the product) and rendered as vertical dashed markers
21
+ * on metric charts, so "the spike started 40 seconds after deploy X" is
22
+ * visible without leaving the chart.
23
+ *
24
+ * Peer of LogItemV3 in layout: tenant projectId, per-row retentionDate
25
+ * TTL, Map(String,String) attributes with a bloom-indexed keys sidecar.
26
+ * Deliberately narrow — this is an annotation stream, not a log store.
27
+ */
28
+
29
+ const readPermissions: Array<Permission> = [
30
+ Permission.ProjectOwner,
31
+ Permission.ProjectAdmin,
32
+ Permission.ProjectMember,
33
+ Permission.Viewer,
34
+ Permission.TelemetryAdmin,
35
+ Permission.TelemetryMember,
36
+ Permission.TelemetryViewer,
37
+ ];
38
+
39
+ const createPermissions: Array<Permission> = [
40
+ Permission.ProjectOwner,
41
+ Permission.ProjectAdmin,
42
+ Permission.ProjectMember,
43
+ Permission.TelemetryAdmin,
44
+ Permission.TelemetryMember,
45
+ ];
46
+
47
+ @OperationalResource()
48
+ @OwnedThrough("primaryEntityId", Service, { includeProjectScope: true })
49
+ export default class ChangeEvent extends AnalyticsBaseModel {
50
+ public constructor() {
51
+ const projectIdColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
52
+ key: "projectId",
53
+ title: "Project ID",
54
+ description: "ID of project",
55
+ required: true,
56
+ type: TableColumnType.ObjectID,
57
+ isTenantId: true,
58
+ accessControl: {
59
+ read: readPermissions,
60
+ create: createPermissions,
61
+ update: [],
62
+ },
63
+ });
64
+
65
+ const primaryEntityIdColumn: AnalyticsTableColumn =
66
+ new AnalyticsTableColumn({
67
+ key: "primaryEntityId",
68
+ title: "Service ID",
69
+ description:
70
+ "ID of the telemetry Service this change belongs to (the deployed service). Optional — project-wide changes carry none.",
71
+ required: false,
72
+ type: TableColumnType.ObjectID,
73
+ accessControl: {
74
+ read: readPermissions,
75
+ create: createPermissions,
76
+ update: [],
77
+ },
78
+ });
79
+
80
+ const primaryEntityTypeColumn: AnalyticsTableColumn =
81
+ new AnalyticsTableColumn({
82
+ key: "primaryEntityType",
83
+ isLowCardinality: true,
84
+ title: "Service Type",
85
+ description: "Discriminator for primaryEntityId",
86
+ required: false,
87
+ type: TableColumnType.Text,
88
+ accessControl: {
89
+ read: readPermissions,
90
+ create: createPermissions,
91
+ update: [],
92
+ },
93
+ });
94
+
95
+ const timeColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
96
+ key: "time",
97
+ title: "Time",
98
+ description: "When the change happened",
99
+ required: true,
100
+ type: TableColumnType.DateTime64,
101
+ accessControl: {
102
+ read: readPermissions,
103
+ create: createPermissions,
104
+ update: [],
105
+ },
106
+ });
107
+
108
+ const eventTypeColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
109
+ key: "eventType",
110
+ isLowCardinality: true,
111
+ title: "Event Type",
112
+ description:
113
+ 'Kind of change — e.g. "deployment", "config-change", "scaling", "rollback", "custom"',
114
+ required: true,
115
+ defaultValue: "deployment",
116
+ type: TableColumnType.Text,
117
+ skipIndex: {
118
+ name: "idx_change_event_type",
119
+ type: SkipIndexType.Set,
120
+ params: [16],
121
+ granularity: 4,
122
+ },
123
+ accessControl: {
124
+ read: readPermissions,
125
+ create: createPermissions,
126
+ update: [],
127
+ },
128
+ });
129
+
130
+ const titleColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
131
+ key: "title",
132
+ title: "Title",
133
+ description: 'Short human label, e.g. "Deploy v2.31.0"',
134
+ required: true,
135
+ type: TableColumnType.Text,
136
+ accessControl: {
137
+ read: readPermissions,
138
+ create: createPermissions,
139
+ update: [],
140
+ },
141
+ });
142
+
143
+ const descriptionColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
144
+ key: "description",
145
+ title: "Description",
146
+ description: "Optional detail — commit message, change summary, links",
147
+ required: false,
148
+ type: TableColumnType.Text,
149
+ accessControl: {
150
+ read: readPermissions,
151
+ create: createPermissions,
152
+ update: [],
153
+ },
154
+ });
155
+
156
+ const attributesColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
157
+ key: "attributes",
158
+ codec: { codec: "ZSTD", level: 3 },
159
+ title: "Attributes",
160
+ description:
161
+ "Arbitrary tags — version, commit sha, pipeline url, environment",
162
+ required: true,
163
+ defaultValue: {},
164
+ type: TableColumnType.MapStringString,
165
+ mapKeysColumn: "attributeKeys",
166
+ accessControl: {
167
+ read: readPermissions,
168
+ create: createPermissions,
169
+ update: [],
170
+ },
171
+ });
172
+
173
+ const attributeKeysColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
174
+ key: "attributeKeys",
175
+ codec: { codec: "ZSTD", level: 3 },
176
+ title: "Attribute Keys",
177
+ description: "Attribute keys extracted from attributes",
178
+ required: true,
179
+ defaultValue: [],
180
+ type: TableColumnType.ArrayText,
181
+ skipIndex: {
182
+ name: "idx_attribute_keys",
183
+ type: SkipIndexType.BloomFilter,
184
+ params: [0.01],
185
+ granularity: 1,
186
+ },
187
+ accessControl: {
188
+ read: readPermissions,
189
+ create: createPermissions,
190
+ update: [],
191
+ },
192
+ });
193
+
194
+ const retentionDateColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
195
+ key: "retentionDate",
196
+ codec: [{ codec: "DoubleDelta" }, { codec: "ZSTD", level: 1 }],
197
+ title: "Retention Date",
198
+ description:
199
+ "Date after which this row is eligible for TTL deletion, computed at ingest time",
200
+ required: true,
201
+ type: TableColumnType.Date,
202
+ defaultValue: undefined,
203
+ });
204
+
205
+ super({
206
+ tableName: AnalyticsTableName.ChangeEvent,
207
+ tableEngine: AnalyticsTableEngine.MergeTree,
208
+ singularName: "Change Event",
209
+ accessControl: {
210
+ read: readPermissions,
211
+ create: createPermissions,
212
+ update: [],
213
+ delete: [
214
+ Permission.ProjectOwner,
215
+ Permission.ProjectAdmin,
216
+ Permission.TelemetryAdmin,
217
+ ],
218
+ },
219
+ pluralName: "Change Events",
220
+ crudApiPath: new Route("/change-events"),
221
+ enableMCP: true,
222
+ enableDocumentation: true,
223
+ tableDescription:
224
+ "Change events (deployments, config changes, scaling) posted by CI/CD pipelines. Rendered as markers on metric charts so spikes can be correlated with what changed.",
225
+ tableColumns: [
226
+ projectIdColumn,
227
+ primaryEntityIdColumn,
228
+ primaryEntityTypeColumn,
229
+ timeColumn,
230
+ eventTypeColumn,
231
+ titleColumn,
232
+ descriptionColumn,
233
+ attributesColumn,
234
+ attributeKeysColumn,
235
+ retentionDateColumn,
236
+ ],
237
+ sortKeys: ["projectId", "time"],
238
+ primaryKeys: ["projectId", "time"],
239
+ partitionKey: "toYYYYMMDD(time)",
240
+ /*
241
+ * Tiny volume (a handful of rows per deploy) — shard by the
242
+ * always-present tenant + time pair; no hotspot risk at this rate.
243
+ */
244
+ shardingKey: "cityHash64(projectId, time)",
245
+ tableSettings:
246
+ "ttl_only_drop_parts = 1, non_replicated_deduplication_window = 10000",
247
+ ttlExpression: "retentionDate DELETE",
248
+ defaultSortColumn: "time",
249
+ });
250
+ }
251
+
252
+ public get projectId(): ObjectID | undefined {
253
+ return this.getColumnValue("projectId") as ObjectID | undefined;
254
+ }
255
+
256
+ public set projectId(v: ObjectID | undefined) {
257
+ this.setColumnValue("projectId", v);
258
+ }
259
+
260
+ public get primaryEntityId(): ObjectID | undefined {
261
+ return this.getColumnValue("primaryEntityId") as ObjectID | undefined;
262
+ }
263
+
264
+ public set primaryEntityId(v: ObjectID | undefined) {
265
+ this.setColumnValue("primaryEntityId", v);
266
+ }
267
+
268
+ public get primaryEntityType(): ServiceType | undefined {
269
+ return this.getColumnValue("primaryEntityType") as ServiceType | undefined;
270
+ }
271
+
272
+ public set primaryEntityType(v: ServiceType | undefined) {
273
+ this.setColumnValue("primaryEntityType", v);
274
+ }
275
+
276
+ public get time(): Date | undefined {
277
+ return this.getColumnValue("time") as Date | undefined;
278
+ }
279
+
280
+ public set time(v: Date | undefined) {
281
+ this.setColumnValue("time", v);
282
+ }
283
+
284
+ public get eventType(): string | undefined {
285
+ return this.getColumnValue("eventType") as string | undefined;
286
+ }
287
+
288
+ public set eventType(v: string | undefined) {
289
+ this.setColumnValue("eventType", v);
290
+ }
291
+
292
+ public get title(): string | undefined {
293
+ return this.getColumnValue("title") as string | undefined;
294
+ }
295
+
296
+ public set title(v: string | undefined) {
297
+ this.setColumnValue("title", v);
298
+ }
299
+
300
+ public get description(): string | undefined {
301
+ return this.getColumnValue("description") as string | undefined;
302
+ }
303
+
304
+ public set description(v: string | undefined) {
305
+ this.setColumnValue("description", v);
306
+ }
307
+
308
+ public get attributes(): JSONObject | undefined {
309
+ return this.getColumnValue("attributes") as JSONObject | undefined;
310
+ }
311
+
312
+ public set attributes(v: JSONObject | undefined) {
313
+ this.setColumnValue("attributes", v);
314
+ }
315
+
316
+ public get attributeKeys(): Array<string> | undefined {
317
+ return this.getColumnValue("attributeKeys") as Array<string> | undefined;
318
+ }
319
+
320
+ public set attributeKeys(v: Array<string> | undefined) {
321
+ this.setColumnValue("attributeKeys", v);
322
+ }
323
+
324
+ public get retentionDate(): Date | undefined {
325
+ return this.getColumnValue("retentionDate") as Date | undefined;
326
+ }
327
+
328
+ public set retentionDate(v: Date | undefined) {
329
+ this.setColumnValue("retentionDate", v);
330
+ }
331
+ }
@@ -21,6 +21,7 @@ import ProfileSample from "./ProfileSample";
21
21
  import RumSession from "./RumSession";
22
22
  import RumSessionChunk from "./RumSessionChunk";
23
23
  import AuditLog from "./AuditLog";
24
+ import ChangeEvent from "./ChangeEvent";
24
25
 
25
26
  const AnalyticsModels: Array<{ new (): AnalyticsBaseModel }> = [
26
27
  Log,
@@ -69,6 +70,7 @@ const AnalyticsModels: Array<{ new (): AnalyticsBaseModel }> = [
69
70
  RumSessionChunk,
70
71
  AuditLog,
71
72
  SecurityEvent,
73
+ ChangeEvent,
72
74
  ];
73
75
 
74
76
  const modelTypeMap: { [key: string]: { new (): AnalyticsBaseModel } } = {};
@@ -1,7 +1,6 @@
1
1
  import EnterpriseLicense from "../../Models/DatabaseModels/EnterpriseLicense";
2
2
  import EnterpriseLicenseInstance from "../../Models/DatabaseModels/EnterpriseLicenseInstance";
3
3
  import BadDataException from "../../Types/Exception/BadDataException";
4
- import { JSONObject } from "../../Types/JSON";
5
4
  import PartialEntity from "../../Types/Database/PartialEntity";
6
5
  import EnterpriseLicenseInstanceSummary from "../../Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary";
7
6
  import EnterpriseLicenseUsageUtil from "../../Utils/EnterpriseLicense/EnterpriseLicenseUsage";
@@ -26,6 +25,20 @@ import {
26
25
  import BaseAPI from "./BaseAPI";
27
26
  // import { Host } from "../EnvironmentConfig";
28
27
 
28
+ /*
29
+ * The license state every installation of a license key mirrors locally.
30
+ * Returned by both /validate and /report-user-count so that the daily report
31
+ * keeps a self-hosted installation current on its own.
32
+ */
33
+ export interface LicenseTerms {
34
+ companyName: string;
35
+ // ISO 8601, or null for a license with no expiry set.
36
+ expiresAt: string | null;
37
+ licenseKey: string;
38
+ // Null means the license carries no seat limit.
39
+ userLimit: number | null;
40
+ }
41
+
29
42
  export interface LicenseInstanceUpsert {
30
43
  licenseId: ObjectID;
31
44
  instanceId: string;
@@ -137,24 +150,15 @@ export default class EnterpriseLicenseAPI extends BaseAPI<
137
150
  const instances: Array<EnterpriseLicenseInstance> =
138
151
  await this.findLicenseInstances(license.id!);
139
152
 
140
- const payload: JSONObject = {
141
- companyName: license.companyName || "",
142
- expiresAt: license.expiresAt.toISOString(),
143
- licenseKey: license.licenseKey || "",
144
- userLimit:
145
- typeof license.userLimit === "number" ? license.userLimit : null,
146
- };
147
-
148
- const token: string = JSONWebToken.signJsonPayload(
149
- payload,
150
- Math.max(secondsUntilExpiry, 1),
151
- );
153
+ const terms: LicenseTerms = this.getLicenseTerms(license);
154
+
155
+ const token: string | null = this.signLicenseToken(license);
152
156
 
153
157
  return Response.sendJsonObjectResponse(req, res, {
154
- companyName: payload["companyName"] as string,
155
- expiresAt: payload["expiresAt"] as string,
156
- licenseKey: payload["licenseKey"] as string,
157
- userLimit: payload["userLimit"],
158
+ companyName: terms.companyName,
159
+ expiresAt: terms.expiresAt,
160
+ licenseKey: terms.licenseKey,
161
+ userLimit: terms.userLimit,
158
162
  currentUserCount:
159
163
  typeof license.currentUserCount === "number"
160
164
  ? license.currentUserCount
@@ -202,8 +206,19 @@ export default class EnterpriseLicenseAPI extends BaseAPI<
202
206
  query: {
203
207
  licenseKey: licenseKey,
204
208
  },
209
+ /*
210
+ * The full set of terms, not just the usage columns: this
211
+ * response is what a self-hosted installation mirrors into its
212
+ * own GlobalConfig every day, so anything it is missing here is
213
+ * a field the customer keeps the stale value of until somebody
214
+ * re-types the license key by hand. Same indexed lookup either
215
+ * way.
216
+ */
205
217
  select: {
206
218
  _id: true,
219
+ companyName: true,
220
+ expiresAt: true,
221
+ licenseKey: true,
207
222
  userLimit: true,
208
223
  isEvaluationLicense: true,
209
224
  },
@@ -282,13 +297,25 @@ export default class EnterpriseLicenseAPI extends BaseAPI<
282
297
  });
283
298
  }
284
299
 
300
+ const terms: LicenseTerms = this.getLicenseTerms(license);
301
+
285
302
  return Response.sendJsonObjectResponse(req, res, {
303
+ companyName: terms.companyName,
304
+ expiresAt: terms.expiresAt,
305
+ licenseKey: terms.licenseKey,
306
+ userLimit: terms.userLimit,
286
307
  currentUserCount: currentUserCount,
287
308
  userCountUpdatedAt: reportedAt.toISOString(),
288
- userLimit:
289
- typeof license.userLimit === "number" ? license.userLimit : null,
290
309
  isEvaluationLicense: Boolean(license.isEvaluationLicense),
291
310
  instances: this.getInstanceSummaries(instances),
311
+ /*
312
+ * Null once the license has expired. Unlike /validate this route
313
+ * does not reject an expired license - the instance is still
314
+ * expected to report, and the expiry notification emails are built
315
+ * from what it reports - but it must not keep being handed a fresh
316
+ * token, and the expiresAt above is what tells it the truth.
317
+ */
318
+ token: this.signLicenseToken(license),
292
319
  });
293
320
  } catch (err) {
294
321
  next(err);
@@ -297,6 +324,54 @@ export default class EnterpriseLicenseAPI extends BaseAPI<
297
324
  );
298
325
  }
299
326
 
327
+ /*
328
+ * The license terms a self-hosted installation mirrors locally. Built in one
329
+ * place so /validate and /report-user-count cannot drift: the seat limit was
330
+ * missing from what an instance could refresh for exactly as long as the two
331
+ * responses were written out by hand, separately.
332
+ */
333
+ private getLicenseTerms(license: EnterpriseLicense): LicenseTerms {
334
+ return {
335
+ companyName: license.companyName || "",
336
+ expiresAt: license.expiresAt ? license.expiresAt.toISOString() : null,
337
+ licenseKey: license.licenseKey || "",
338
+ userLimit:
339
+ typeof license.userLimit === "number" ? license.userLimit : null,
340
+ };
341
+ }
342
+
343
+ /*
344
+ * Signed with the license server's own secret, so it is opaque to the
345
+ * installation holding it - it is the proof that oneuptime.com recognized
346
+ * the key, and the instance only checks that it exists. An expired license
347
+ * gets none, which is why this returns null rather than a short-lived token.
348
+ */
349
+ private signLicenseToken(license: EnterpriseLicense): string | null {
350
+ if (!license.expiresAt) {
351
+ return null;
352
+ }
353
+
354
+ const secondsUntilExpiry: number = Math.floor(
355
+ (license.expiresAt.getTime() - Date.now()) / 1000,
356
+ );
357
+
358
+ if (secondsUntilExpiry <= 0) {
359
+ return null;
360
+ }
361
+
362
+ const terms: LicenseTerms = this.getLicenseTerms(license);
363
+
364
+ return JSONWebToken.signJsonPayload(
365
+ {
366
+ companyName: terms.companyName,
367
+ expiresAt: terms.expiresAt,
368
+ licenseKey: terms.licenseKey,
369
+ userLimit: terms.userLimit,
370
+ },
371
+ Math.max(secondsUntilExpiry, 1),
372
+ );
373
+ }
374
+
300
375
  private parseShortText(value: unknown): string {
301
376
  if (typeof value !== "string") {
302
377
  return "";
@@ -369,16 +369,26 @@ export const AnalyticsKey: string = process.env["ANALYTICS_KEY"] || "";
369
369
  export const AnalyticsHost: string = process.env["ANALYTICS_HOST"] || "";
370
370
 
371
371
  /*
372
- * Shared secret Cal.com signs booking webhooks with (App/API/CalWebhook.ts).
373
- * Server-only must never be added to FRONTEND_ENV_ALLOW_LIST. Empty means
374
- * the webhook endpoint is disabled and answers 503.
372
+ * Whether this deployment loads the Google Tag Manager container and pushes
373
+ * marketing conversions into it.
374
+ *
375
+ * Billing being enabled is necessary — a self-hosted install must never load
376
+ * googletagmanager.com — but it is not sufficient. CI runs the SaaS end-to-end
377
+ * suite with BILLING_ENABLED forced to true, and those scripted registrations
378
+ * fire a real `sign_up` into the production container: the key-events trigger
379
+ * matches on the event name alone, with no hostname condition, and GA4 data
380
+ * filters do not apply retroactively. This is the second condition, so an
381
+ * environment can keep billing on and still stay out of the container.
382
+ *
383
+ * Defaults to on, so the hosted product needs no deploy change.
375
384
  */
376
- export const CalWebhookSecret: string = process.env["CAL_WEBHOOK_SECRET"] || "";
385
+ export const GoogleTagManagerEnabled: boolean =
386
+ IsBillingEnabled && process.env["GOOGLE_TAG_MANAGER_ENABLED"] !== "false";
377
387
 
378
388
  /*
379
389
  * Outbound marketing conversion webhooks.
380
390
  *
381
- * OneUptime does not store conversions. Signups, booked meetings and plan
391
+ * OneUptime does not store conversions. Signups and plan
382
392
  * upgrades/downgrades are POSTed to this endpoint as they happen and kept
383
393
  * nowhere afterwards, so an unset URL means those moments are simply not
384
394
  * measured — nothing accumulates waiting for one to be configured.
@@ -14,6 +14,10 @@ export class AddUserSlackAndMicrosoftTeams1789000000000
14
14
  public name = "AddUserSlackAndMicrosoftTeams1789000000000";
15
15
 
16
16
  public async up(queryRunner: QueryRunner): Promise<void> {
17
+ if (await this.isAlreadyApplied(queryRunner)) {
18
+ return;
19
+ }
20
+
17
21
  await queryRunner.query(
18
22
  `CREATE TABLE "UserSlack" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "projectId" uuid NOT NULL, "slackUserId" character varying(100), "slackUserName" character varying(100), "userId" uuid, "createdByUserId" uuid, "deletedByUserId" uuid, "isVerified" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_86de4cd76b41a08811fc403e23a" PRIMARY KEY ("_id"))`,
19
23
  );
@@ -106,6 +110,44 @@ export class AddUserSlackAndMicrosoftTeams1789000000000
106
110
  );
107
111
  }
108
112
 
113
+ /*
114
+ * Did everything in `up` already run, under this migration's OLD name?
115
+ *
116
+ * This shipped as AddUserSlackAndMicrosoftTeams1788800000000 and was
117
+ * renumbered to 1789000000000 to clear a timestamp collision with
118
+ * DropMarketingConversionAddEnterpriseLicenseEmail. TypeORM identifies a
119
+ * migration by class NAME rather than by what it does, so every database
120
+ * that had already recorded the old name — anything tracking master between
121
+ * the two commits, which is every test and staging cluster — sees the
122
+ * renamed class as pending and runs it a second time, where
123
+ * `CREATE TABLE "UserSlack"` fails on the table it created itself. The
124
+ * runner stops at the first failure, so that single error holds back this
125
+ * migration and every migration added after it, on every boot.
126
+ *
127
+ * The probe is the table because the earlier run was all-or-nothing:
128
+ * migrations run one transaction each (DataSourceOptions sets
129
+ * migrationsTransactionMode: "each") and Postgres DDL is transactional, so
130
+ * "UserSlack" existing means every statement in `up` already committed.
131
+ * Nothing is left to do but let TypeORM record the new name.
132
+ *
133
+ * The superseded row in "migrations" is deliberately left alone. Deleting it
134
+ * would make this migration pending again for anyone who rolls the image
135
+ * BACK to a build that still registers the old name — the same failure, in
136
+ * the other direction. An unrecognised name in that table is inert.
137
+ */
138
+ private async isAlreadyApplied(queryRunner: QueryRunner): Promise<boolean> {
139
+ const userSlackTableExists: Array<{ exists: boolean }> =
140
+ await queryRunner.query(`
141
+ SELECT EXISTS (
142
+ SELECT FROM information_schema.tables
143
+ WHERE table_schema = 'public'
144
+ AND table_name = 'UserSlack'
145
+ )
146
+ `);
147
+
148
+ return Boolean(userSlackTableExists[0]?.exists);
149
+ }
150
+
109
151
  public async down(queryRunner: QueryRunner): Promise<void> {
110
152
  await queryRunner.query(
111
153
  `ALTER TABLE "UserOnCallLogTimeline" DROP CONSTRAINT "FK_a7206c19df5cdfe02cf3215a64c"`,
@@ -542,7 +542,6 @@ import { RemoveLlmCostBudgetAlertColumns1788500000000 } from "./1788500000000-Re
542
542
  import { AddDetectionRuleIncidentColumns1788600000000 } from "./1788600000000-AddDetectionRuleIncidentColumns";
543
543
  import { RemoveMarketingConversionUploadState1788700000000 } from "./1788700000000-RemoveMarketingConversionUploadState";
544
544
  import { DropMarketingConversionAddEnterpriseLicenseEmail1788800000000 } from "./1788800000000-DropMarketingConversionAddEnterpriseLicenseEmail";
545
- import { RedactStoredMonitorIngestSecrets1788900000000 } from "./1788900000000-RedactStoredMonitorIngestSecrets";
546
545
  import { AddUserSlackAndMicrosoftTeams1789000000000 } from "./1789000000000-AddUserSlackAndMicrosoftTeams";
547
546
  import { MigrationName1787142779538 } from "./1787142779538-MigrationName";
548
547
  import { MigrationName1787156982416 } from "./1787156982416-MigrationName";
@@ -1094,6 +1093,5 @@ export default [
1094
1093
  AddDetectionRuleIncidentColumns1788600000000,
1095
1094
  RemoveMarketingConversionUploadState1788700000000,
1096
1095
  DropMarketingConversionAddEnterpriseLicenseEmail1788800000000,
1097
- RedactStoredMonitorIngestSecrets1788900000000,
1098
1096
  AddUserSlackAndMicrosoftTeams1789000000000,
1099
1097
  ];
@@ -0,0 +1,11 @@
1
+ import ClickhouseDatabase from "../Infrastructure/ClickhouseDatabase";
2
+ import AnalyticsDatabaseService from "./AnalyticsDatabaseService";
3
+ import ChangeEvent from "../../Models/AnalyticsModels/ChangeEvent";
4
+
5
+ export class ChangeEventService extends AnalyticsDatabaseService<ChangeEvent> {
6
+ public constructor(clickhouseDatabase?: ClickhouseDatabase | undefined) {
7
+ super({ modelType: ChangeEvent, database: clickhouseDatabase });
8
+ }
9
+ }
10
+
11
+ export default new ChangeEventService();
@@ -37,9 +37,9 @@ export class Service extends DatabaseService<EnterpriseLicense> {
37
37
  email: createdItem.email?.toString(),
38
38
  /*
39
39
  * No attribution source. A licence row is typed in by a human and
40
- * carries no session, so its campaign is whatever the meeting_booked
41
- * sharing its email carried — the join the receiver makes on
42
- * emailHash, not something OneUptime can restate here.
40
+ * carries no session, so its campaign is whatever the earlier
41
+ * conversion sharing its email carried — the join the receiver makes
42
+ * on emailHash, not something OneUptime can restate here.
43
43
  */
44
44
  data: {
45
45
  enterpriseLicenseId: createdItem.id?.toString() || "",
@@ -77,6 +77,7 @@ import DataSourceService from "./DataSourceService";
77
77
  import AuditLogService from "./AuditLogService";
78
78
  import LogService from "./LogService";
79
79
  import SecurityEventService from "./SecurityEventService";
80
+ import ChangeEventService from "./ChangeEventService";
80
81
  import MailService from "./MailService";
81
82
  import MetricService from "./MetricService";
82
83
  import MetricItemAggMV1mService from "./MetricItemAggMV1mService";
@@ -641,6 +642,7 @@ export const AnalyticsServices: Array<
641
642
  RumSessionChunkService,
642
643
  AuditLogService,
643
644
  SecurityEventService,
645
+ ChangeEventService,
644
646
  ];
645
647
 
646
648
  export default services;