@oneuptime/common 12.0.33 → 12.0.34
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/RumSession.ts +117 -1
- package/Models/AnalyticsModels/RumSessionChunk.ts +13 -0
- package/Models/DatabaseModels/AlertCustomField.ts +77 -3
- package/Models/DatabaseModels/ApiKeyPermission.ts +3 -27
- package/Models/DatabaseModels/IncidentCustomField.ts +77 -3
- package/Models/DatabaseModels/InventoryItemCustomField.ts +77 -3
- package/Models/DatabaseModels/MonitorCustomField.ts +77 -3
- package/Models/DatabaseModels/OnCallDutyPolicyCustomField.ts +77 -3
- package/Models/DatabaseModels/RumApplication.ts +6 -6
- package/Models/DatabaseModels/ScheduledMaintenanceCustomField.ts +77 -3
- package/Models/DatabaseModels/StatusPageCustomField.ts +77 -3
- package/Models/DatabaseModels/TeamCustomField.ts +77 -3
- package/Models/DatabaseModels/TeamMember.ts +0 -9
- package/Models/DatabaseModels/TeamMemberCustomField.ts +77 -3
- package/Models/DatabaseModels/TeamPermission.ts +3 -24
- package/Models/DatabaseModels/TelemetryIngestionKey.ts +260 -0
- package/Models/DatabaseModels/UserTelegram.ts +0 -4
- package/Scripts/benchmark-fanin-capacity.js +181 -0
- package/Server/API/TelemetryAPI.ts +1284 -164
- package/Server/API/UserNotificationSettingAPI.ts +55 -0
- package/Server/API/UserTelegramAPI.ts +48 -4
- package/Server/EnvironmentConfig.ts +35 -2
- package/Server/Infrastructure/Postgres/SchemaMigrations/1791300000000-AddTelemetryIngestionKeyType.ts +91 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1791400000000-SessionReplayRecordEverySessionByDefault.ts +67 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1791500000000-WidenCustomFieldDropdownOptions.ts +139 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1791600000000-AddCustomFieldValueMapping.ts +121 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
- package/Server/Infrastructure/QueueWorker.ts +20 -14
- package/Server/Infrastructure/Semaphore.ts +2 -0
- package/Server/Middleware/TelemetryIngest.ts +453 -7
- package/Server/Services/AccessTokenService.ts +1 -0
- package/Server/Services/AlertCustomFieldService.ts +98 -0
- package/Server/Services/AlertService.ts +37 -0
- package/Server/Services/ApiKeyPermissionService.ts +461 -29
- package/Server/Services/CustomFieldMappingService.ts +879 -0
- package/Server/Services/GlobalConfigService.ts +194 -0
- package/Server/Services/IncidentCustomFieldService.ts +98 -0
- package/Server/Services/IncidentService.ts +35 -0
- package/Server/Services/MetricService.ts +194 -0
- package/Server/Services/MonitorService.ts +32 -0
- package/Server/Services/NetworkDeviceAutoImportRuleEngineService.ts +78 -25
- package/Server/Services/ProjectService.ts +22 -0
- package/Server/Services/RoutineEmailSettingsService.ts +73 -0
- package/Server/Services/RumSessionReplayViewService.ts +104 -24
- package/Server/Services/ScheduledMaintenanceCustomFieldService.ts +98 -0
- package/Server/Services/ScheduledMaintenanceService.ts +32 -0
- package/Server/Services/TeamMemberService.ts +78 -0
- package/Server/Services/TeamPermissionService.ts +235 -3
- package/Server/Services/TelemetryIngestionKeyService.ts +722 -19
- package/Server/Services/UserNotificationSettingService.ts +34 -6
- package/Server/Services/UserTelegramService.ts +326 -5
- package/Server/Types/Database/QueryHelper.ts +2 -0
- package/Server/Utils/APIKey/AccessPermission.ts +5 -2
- package/Server/Utils/CustomField/CustomFieldDefinitionMappingHooks.ts +54 -0
- package/Server/Utils/CustomField/CustomFieldMappingRegistry.ts +415 -0
- package/Server/Utils/CustomField/CustomFieldMappingValidator.ts +335 -0
- package/Server/Utils/DataSource/EgressGuard.ts +177 -8
- package/Server/Utils/EmailRollup/EmailRollupConstants.ts +38 -5
- package/Server/Utils/EmailRollup/EmailRollupFlushRunner.ts +57 -4
- package/Server/Utils/FrontendEnvironment.ts +40 -0
- package/Server/Utils/LogRedaction.ts +20 -0
- package/Server/Utils/Logger.ts +11 -0
- package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +338 -77
- package/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.ts +30 -4
- package/Server/Utils/Monitor/MonitorAlert.ts +29 -6
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +110 -15
- package/Server/Utils/Monitor/MonitorIncident.ts +22 -6
- package/Server/Utils/Monitor/MonitorTemplateUtil.ts +29 -0
- package/Server/Utils/Monitor/SeriesContextEnricher.ts +304 -0
- package/Server/Utils/SSRFProtection.ts +217 -10
- package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +239 -138
- package/Server/Utils/SessionReplay/SessionReplayHealthCounters.ts +305 -0
- package/Server/Utils/SessionReplay/SessionReplayReadService.ts +1430 -289
- package/Server/Utils/StartServer.ts +2 -17
- package/Server/Utils/TelegramVerificationToken.ts +185 -0
- package/Server/Utils/Telemetry/PinServiceName.ts +197 -0
- package/Server/Utils/Telemetry/TelemetryFanInWriter.ts +38 -16
- package/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.ts +92 -0
- package/Server/Utils/Telemetry/TelemetryIngestionKeyRateLimiter.ts +217 -0
- package/Server/Utils/VM/VMAPI.ts +1 -0
- package/Server/Utils/VM/VMRunner.ts +975 -95
- package/Tests/App/AdminDashboard/AdminHeaderSmallScreens.test.tsx +202 -0
- package/Tests/App/Dashboard/DashboardHeaderSmallScreens.test.tsx +404 -0
- package/Tests/App/Dashboard/DiscoveryScanWizardValidation.test.tsx +468 -0
- package/Tests/App/Dashboard/NotificationEmailPreferences.test.tsx +399 -0
- package/Tests/App/StatusPage/StatusPageLastUpdated.test.tsx +263 -0
- package/Tests/App/StatusPage/StatusPageOverviewLiveAndSearch.test.tsx +753 -0
- package/Tests/App/StatusPage/StatusPageResourceSearchBox.test.tsx +224 -0
- package/Tests/Models/AnalyticsModels/RumSessionReplayColumns.test.ts +262 -0
- package/Tests/Models/CustomFieldMappingColumns.test.ts +166 -0
- package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +20 -6
- package/Tests/ResponsiveVisibility.test.ts +115 -0
- package/Tests/ResponsiveVisibility.ts +183 -0
- package/Tests/Server/API/SessionReplayAPI.test.ts +1940 -216
- package/Tests/Server/API/UserNotificationSettingAPI.test.ts +186 -0
- package/Tests/Server/API/UserTelegramAPISecurity.test.ts +231 -0
- package/Tests/Server/EnvironmentConfigFrontendSecurity.test.ts +234 -0
- package/Tests/Server/Infrastructure/Postgres/CustomFieldValueMappingMigration.test.ts +202 -0
- package/Tests/Server/Infrastructure/Postgres/InventoryItemArchiveMigration.test.ts +41 -0
- package/Tests/Server/Infrastructure/Postgres/SessionReplayRecordEverySessionByDefaultMigration.test.ts +169 -0
- package/Tests/Server/Infrastructure/QueueWorkerTimeout.test.ts +210 -0
- package/Tests/Server/Infrastructure/TelemetryExporterDeploymentConfig.test.ts +130 -0
- package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +4 -1
- package/Tests/Server/Middleware/TelemetryIngestBrowserKey.test.ts +1163 -0
- package/Tests/Server/Middleware/TelemetryIngestTokenLog.test.ts +5 -3
- package/Tests/Server/Services/AddTelemetryIngestionKeyTypeMigration.test.ts +555 -0
- package/Tests/Server/Services/ApiKeyPermissionSecurity.test.ts +678 -0
- package/Tests/Server/Services/ApiKeyPermissionService.test.ts +276 -28
- package/Tests/Server/Services/CustomFieldDropdownOptionsColumnWidth.test.ts +431 -0
- package/Tests/Server/Services/CustomFieldMappingService.test.ts +850 -0
- package/Tests/Server/Services/DiscoveryScanClaimHookFreeSafety.test.ts +32 -0
- package/Tests/Server/Services/GlobalConfigService.test.ts +414 -1
- package/Tests/Server/Services/MetricRawEntityKeyPrune.test.ts +592 -0
- package/Tests/Server/Services/NetworkDeviceAutoImportRuleEngineService.test.ts +226 -1
- package/Tests/Server/Services/RoutineEmailSettingsPostgres.test.ts +329 -0
- package/Tests/Server/Services/RumSessionReplayViewService.test.ts +267 -0
- package/Tests/Server/Services/TeamMemberAutoAcceptInvitation.test.ts +9 -0
- package/Tests/Server/Services/TeamMemberInviteRegistrationToken.test.ts +9 -0
- package/Tests/Server/Services/TeamPrivilegeEscalation.test.ts +834 -0
- package/Tests/Server/Services/TelemetryIngestionKeyPolicyResolution.test.ts +814 -0
- package/Tests/Server/Services/TelemetryIngestionKeyValidation.test.ts +998 -0
- package/Tests/Server/Services/UserNotificationSettingRollupRouting.test.ts +141 -0
- package/Tests/Server/Services/UserNotificationSettingWorkspaceChannels.test.ts +18 -10
- package/Tests/Server/Services/UserTelegramVerificationSecurity.test.ts +688 -0
- package/Tests/Server/Utils/AI/SRE/Insights/FixRouting.test.ts +497 -0
- package/Tests/Server/Utils/AI/Toolbox/Serializer.test.ts +383 -0
- package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +7 -3
- package/Tests/Server/Utils/AnalyticsDatabase/ClusterConfig.test.ts +315 -0
- package/Tests/Server/Utils/CustomField/CustomFieldMappingValidator.test.ts +437 -0
- package/Tests/Server/Utils/DataSource/EgressGuard.test.ts +300 -11
- package/Tests/Server/Utils/EmailRollup/EmailRollupBurstWindow.test.ts +83 -0
- package/Tests/Server/Utils/EmailRollup/EmailRollupFlushRunnerBehaviour.test.ts +1 -1
- package/Tests/Server/Utils/EmailRollup/EmailRollupFlushRunnerPreferences.test.ts +362 -0
- package/Tests/Server/Utils/EmailRollup/EmailRollupTestHarness.ts +77 -1
- package/Tests/Server/Utils/FrontendEnvironment.test.ts +190 -0
- package/Tests/Server/Utils/LogRedaction.test.ts +26 -0
- package/Tests/Server/Utils/LoggerCredentialLeak.test.ts +23 -9
- package/Tests/Server/Utils/Monitor/Criteria/CompareCriteriaAggregation.test.ts +584 -0
- package/Tests/Server/Utils/Monitor/Criteria/IncomingRequestHeaderCriteria.test.ts +236 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluator.test.ts +437 -0
- package/Tests/Server/Utils/Monitor/MonitorTemplateUtilSeriesContext.test.ts +176 -0
- package/Tests/Server/Utils/Monitor/SeriesContextEnricher.test.ts +356 -0
- package/Tests/Server/Utils/SSRFProtectionCloudServiceAddresses.test.ts +595 -0
- package/Tests/Server/Utils/SessionReplay/SessionReplayGateCachePolicy.test.ts +198 -1
- package/Tests/Server/Utils/SessionReplay/SessionReplayHealthCounters.test.ts +453 -0
- package/Tests/Server/Utils/SessionReplay/SessionReplayReadServiceQueries.test.ts +1378 -0
- package/Tests/Server/Utils/SessionReplayOriginAllowListRefactor.test.ts +420 -0
- package/Tests/Server/Utils/TelegramVerificationToken.test.ts +253 -0
- package/Tests/Server/Utils/Telemetry/PinServiceName.test.ts +666 -0
- package/Tests/Server/Utils/Telemetry/TelemetryFanInWriterCapacity.test.ts +458 -0
- package/Tests/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.test.ts +424 -0
- package/Tests/Server/Utils/Telemetry/TelemetryIngestionKeyRateLimiter.test.ts +528 -0
- package/Tests/Server/Utils/TelemetryExporterEnvironment.test.ts +56 -0
- package/Tests/Server/Utils/VM/VMRunnerHostBridgeLatency.test.ts +74 -0
- package/Tests/Server/Utils/VM/VMRunnerPrivateNetworkWiring.test.ts +100 -7
- package/Tests/Server/Utils/VM/VMRunnerSsrf.test.ts +1176 -6
- package/Tests/Types/CustomField/CustomFieldMappingCatalog.test.ts +220 -0
- package/Tests/Types/CustomField/CustomFieldValueMapping.test.ts +348 -0
- package/Tests/Types/JSONFunctions.test.ts +260 -0
- package/Tests/Types/Monitor/CephAlertTemplates.test.ts +422 -53
- package/Tests/Types/Monitor/DockerAlertTemplates.test.ts +495 -15
- package/Tests/Types/Monitor/DockerSwarmAlertTemplates.test.ts +191 -45
- package/Tests/Types/Monitor/HostAlertTemplates.test.ts +370 -42
- package/Tests/Types/Monitor/IotAlertTemplates.test.ts +375 -20
- package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +636 -42
- package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +189 -0
- package/Tests/Types/Monitor/KubernetesTemplateGroupByKeys.test.ts +95 -14
- package/Tests/Types/Monitor/PodmanAlertTemplates.test.ts +190 -21
- package/Tests/Types/Monitor/ProxmoxAlertTemplates.test.ts +352 -22
- package/Tests/Types/Monitor/Recommendation/MonitorRecommendationAlertDebuggability.test.ts +363 -0
- package/Tests/Types/Monitor/Recommendation/MonitorRecommendationNotificationMode.test.ts +93 -18
- package/Tests/Types/Monitor/Recommendation/MonitorRecommendationSeverityMapper.test.ts +259 -0
- package/Tests/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.test.ts +476 -0
- package/Tests/Types/Monitor/RumAlertTemplates.test.ts +136 -0
- package/Tests/Types/Monitor/SeriesContext/SeriesDebugHints.test.ts +661 -0
- package/Tests/Types/Monitor/SeriesContext/SeriesLabelDisplay.test.ts +507 -0
- package/Tests/Types/Monitor/ServiceAlertTemplates.test.ts +270 -0
- package/Tests/Types/Monitor/TemplateGroupByKeys.test.ts +38 -10
- package/Tests/Types/Monitor/Utils/RecommendationCriteriaAssertions.ts +102 -0
- package/Tests/Types/NotificationSetting/RoutineEmailEvents.test.ts +47 -0
- package/Tests/Types/Rum/SessionReplayApiContracts.test.ts +608 -0
- package/Tests/Types/Rum/SessionReplayCustomEvents.test.ts +434 -0
- package/Tests/Types/WebsiteRequest.test.ts +285 -3
- package/Tests/UI/Components/Charts/ChartBucketIdentity.test.ts +16 -3
- package/Tests/UI/Components/Charts/ChartTrailingBucketGap.test.ts +691 -0
- package/Tests/UI/Components/ComponentsModal.test.tsx +12 -4
- package/Tests/UI/Components/ComponentsModalUsability.test.tsx +518 -0
- package/Tests/UI/Components/CustomFields/CustomFieldsDetailMapping.test.tsx +324 -0
- package/Tests/UI/Components/CustomFields/MapFromCustomFieldInput.test.tsx +176 -0
- package/Tests/UI/Components/Graphs/DayUptimeGraph.test.tsx +484 -0
- package/Tests/UI/Components/HeaderRightRail.test.tsx +157 -0
- package/Tests/UI/Components/IconDropdownItem.test.tsx +102 -0
- package/Tests/UI/Components/JSONTablePrototypePollution.test.tsx +117 -0
- package/Tests/UI/Components/KeyboardShortcutsModal.test.tsx +214 -0
- package/Tests/UI/Components/ModelTable/ModelTableWrapContent.test.tsx +561 -0
- package/Tests/UI/Components/MonitorGraphs/UptimeBarDayModal.test.tsx +302 -0
- package/Tests/UI/Components/MonitorTemplateVariables/TemplateVariablesCatalog.test.ts +28 -12
- package/Tests/UI/Components/ShortcutDialogGuard.test.tsx +74 -0
- package/Tests/UI/Components/StatusPage/ResourceGroupSectionAutoExpand.test.tsx +248 -0
- package/Tests/UI/Components/TableCellWrapping.test.tsx +625 -0
- package/Tests/UI/Components/TableLoadingStates.test.tsx +205 -0
- package/Tests/UI/Components/Workflow/NodePlacement.test.ts +148 -0
- package/Tests/UI/Components/Workflow/Workflow.test.tsx +981 -0
- package/Tests/UI/ConfigBrowserTelemetry.test.ts +70 -0
- package/Tests/UI/Rum/ChunkLoader.test.ts +1036 -2
- package/Tests/UI/Rum/FidelityNotices.test.ts +187 -0
- package/Tests/UI/Rum/InactivityMap.test.ts +341 -0
- package/Tests/UI/Rum/PrivacySummaryCard.test.tsx +259 -0
- package/Tests/UI/Rum/RecordingHealthCard.test.tsx +855 -0
- package/Tests/UI/Rum/RecordingHealthStrip.test.tsx +594 -0
- package/Tests/UI/Rum/ReplayCard.test.tsx +451 -0
- package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +536 -0
- package/Tests/UI/Rum/ReplayEngine.test.ts +2503 -0
- package/Tests/UI/Rum/ReplayEngineTypes.test.ts +171 -0
- package/Tests/UI/Rum/ReplayHeader.test.tsx +711 -0
- package/Tests/UI/Rum/ReplayLink.test.tsx +119 -0
- package/Tests/UI/Rum/ReplayPinControl.test.tsx +408 -0
- package/Tests/UI/Rum/ReplayPlaybackIntent.test.ts +152 -0
- package/Tests/UI/Rum/ReplayRail.test.tsx +1296 -0
- package/Tests/UI/Rum/ReplayRailDetail.test.tsx +900 -0
- package/Tests/UI/Rum/ReplayScrubber.test.tsx +763 -147
- package/Tests/UI/Rum/ReplaySignalTypes.test.ts +189 -0
- package/Tests/UI/Rum/ReplaySignals.test.ts +1579 -0
- package/Tests/UI/Rum/ReplayStage.test.tsx +396 -473
- package/Tests/UI/Rum/ReplayStageOverlays.test.tsx +918 -0
- package/Tests/UI/Rum/ReplayTimeline.test.tsx +696 -0
- package/Tests/UI/Rum/SessionReplayEmptyState.test.tsx +499 -0
- package/Tests/UI/Rum/SessionReplaySearchBar.test.tsx +346 -0
- package/Tests/UI/Rum/SessionReplaySetupGuide.test.tsx +545 -0
- package/Tests/UI/Rum/SessionReplayTable.test.tsx +991 -0
- package/Tests/UI/Rum/TargetedCapturePanel.test.tsx +254 -0
- package/Tests/UI/Telemetry/BrowserExporterIsolation.test.ts +71 -0
- package/Tests/UI/Utils/GlobalKeyboardShortcut.test.ts +344 -0
- package/Tests/Utils/API.test.ts +308 -0
- package/Tests/Utils/HTTPResponseBodyReader.test.ts +323 -0
- package/Tests/Utils/NetworkDiscovery/DiscoveryScanStatus.test.ts +117 -0
- package/Tests/Utils/Rum/ChunkMath.test.ts +83 -0
- package/Tests/Utils/Rum/SessionReplayHealthDiagnosis.test.ts +993 -0
- package/Tests/Utils/Rum/SessionReplayStringMap.test.ts +245 -0
- package/Tests/Utils/StatusPage/ResourceSearch.test.ts +631 -0
- package/Tests/Utils/Telemetry/OriginAllowList.test.ts +633 -0
- package/Tests/Utils/Uptime/DayUptimeGraphUtil.test.ts +461 -0
- package/Tests/Utils/ValueFormatter.test.ts +148 -0
- package/Types/CustomField/CustomFieldMappingCatalog.ts +161 -0
- package/Types/CustomField/CustomFieldMappingSourceResource.ts +19 -0
- package/Types/CustomField/CustomFieldValueMapping.ts +288 -0
- package/Types/Icon/IconProp.ts +1 -0
- package/Types/JSONFunctions.ts +189 -43
- package/Types/Monitor/CephAlertTemplates.ts +157 -195
- package/Types/Monitor/DockerAlertTemplates.ts +338 -128
- package/Types/Monitor/DockerSwarmAlertTemplates.ts +95 -112
- package/Types/Monitor/HostAlertTemplates.ts +302 -130
- package/Types/Monitor/IotAlertTemplates.ts +112 -104
- package/Types/Monitor/KubernetesAlertTemplates.ts +415 -224
- package/Types/Monitor/KubernetesMetricCatalog.ts +23 -19
- package/Types/Monitor/PodmanAlertTemplates.ts +116 -163
- package/Types/Monitor/ProxmoxAlertTemplates.ts +217 -99
- package/Types/Monitor/Recommendation/MonitorRecommendationSeverityMapper.ts +98 -6
- package/Types/Monitor/Recommendation/MonitorRecommendationUtil.ts +44 -7
- package/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.ts +383 -0
- package/Types/Monitor/RumAlertTemplates.ts +46 -7
- package/Types/Monitor/SeriesContext/SeriesDebugHints.ts +596 -0
- package/Types/Monitor/SeriesContext/SeriesLabelDisplay.ts +554 -0
- package/Types/Monitor/ServiceAlertTemplates.ts +98 -11
- package/Types/Monitor/UptimeHistoryLabels.ts +56 -0
- package/Types/NotificationSetting/RoutineEmailEvents.ts +30 -0
- package/Types/Rum/SessionReplay.ts +209 -0
- package/Types/Rum/SessionReplayApi.ts +722 -0
- package/Types/Rum/SessionReplayCaptureTrigger.ts +21 -12
- package/Types/Rum/SessionReplayConsentMode.ts +11 -7
- package/Types/Rum/SessionReplayCustomEvents.ts +549 -0
- package/Types/Rum/SessionReplayHealth.ts +238 -0
- package/Types/Telemetry/TelemetryIngestSurface.ts +95 -0
- package/Types/Telemetry/TelemetryIngestionKeyPolicy.ts +75 -0
- package/Types/Telemetry/TelemetryIngestionKeyType.ts +29 -0
- package/Types/WebsiteRequest.ts +85 -3
- package/UI/Components/Charts/Area/AreaChart.tsx +13 -2
- package/UI/Components/Charts/Bar/BarChart.tsx +6 -2
- package/UI/Components/Charts/Line/LineChart.tsx +13 -2
- package/UI/Components/Charts/Types/XAxis/XAxis.ts +29 -0
- package/UI/Components/Charts/Utils/DataPoint.ts +22 -3
- package/UI/Components/Charts/Utils/TimeAnnotation.ts +76 -9
- package/UI/Components/Charts/Utils/XAxis.ts +217 -0
- package/UI/Components/CustomFields/CustomFieldsDetail.tsx +143 -25
- package/UI/Components/CustomFields/MapFromCustomFieldInput.tsx +216 -0
- package/UI/Components/Graphs/DayUptimeGraph.tsx +173 -8
- package/UI/Components/Graphs/UptimeBarTooltip.tsx +22 -278
- package/UI/Components/Graphs/UptimeDaySummary.tsx +327 -0
- package/UI/Components/Header/Header.tsx +25 -9
- package/UI/Components/Header/IconDropdown/IconDropdownItem.tsx +10 -1
- package/UI/Components/Header/IconDropdown/IconDropdownMenu.tsx +6 -1
- package/UI/Components/Header/ProjectPicker/ProjectPicker.tsx +6 -1
- package/UI/Components/HeaderAlert/NotificationBell/NotificationBellDropdown.tsx +6 -1
- package/UI/Components/Icon/Icon.tsx +11 -0
- package/UI/Components/JSONTable/JSONTable.tsx +2 -2
- package/UI/Components/KeyboardShortcut/KeyboardShortcutsModal.tsx +125 -0
- package/UI/Components/KeyboardShortcut/Screenshots/README.md +19 -0
- package/UI/Components/KeyboardShortcut/Screenshots/keyboard-shortcuts-command-palette.png +0 -0
- package/UI/Components/KeyboardShortcut/Screenshots/keyboard-shortcuts-dialog-dark.png +0 -0
- package/UI/Components/KeyboardShortcut/Screenshots/keyboard-shortcuts-dialog.png +0 -0
- package/UI/Components/KeyboardShortcut/Screenshots/keyboard-shortcuts-help-menu.png +0 -0
- package/UI/Components/KeyboardShortcut/Screenshots/keyboard-shortcuts-narrow.png +0 -0
- package/UI/Components/ModelTable/Column.ts +15 -0
- package/UI/Components/Monitor/SeriesDebugCommandsViewer.tsx +64 -0
- package/UI/Components/Monitor/SeriesLabelsViewer.tsx +93 -0
- package/UI/Components/MonitorGraphs/Uptime.tsx +14 -2
- package/UI/Components/MonitorGraphs/UptimeBarDayModal.tsx +63 -10
- package/UI/Components/MonitorTemplateVariables/TemplateVariablesCatalog.ts +44 -0
- package/UI/Components/StatusPage/ResourceGroupSection.tsx +40 -0
- package/UI/Components/Table/CellClassName.ts +81 -0
- package/UI/Components/Table/TableRow.tsx +20 -14
- package/UI/Components/Table/TableSkeletonRows.tsx +9 -9
- package/UI/Components/Table/Types/Column.ts +36 -0
- package/UI/Components/Tooltip/Tooltip.tsx +11 -1
- package/UI/Components/Workflow/ComponentsModal.tsx +60 -39
- package/UI/Components/Workflow/NodePlacement.ts +57 -0
- package/UI/Components/Workflow/Workflow.tsx +47 -18
- package/UI/Config.ts +15 -33
- package/UI/Utils/GlobalKeyboardShortcut.ts +208 -0
- package/UI/Utils/Telemetry/BrowserTelemetryConfig.ts +28 -0
- package/UI/Utils/Telemetry/Telemetry.ts +10 -8
- package/Utils/API.ts +129 -3
- package/Utils/HTTPResponseBodyReader.ts +221 -0
- package/Utils/NetworkDiscovery/DiscoveryScanStatus.ts +77 -0
- package/Utils/Rum/ChunkMath.ts +106 -0
- package/Utils/Rum/SessionReplayHealth.ts +732 -0
- package/Utils/Rum/SessionReplayStringMap.ts +226 -0
- package/Utils/Schema/ModelSchema.ts +1 -0
- package/Utils/StatusPage/ResourceSearch.ts +290 -0
- package/Utils/Telemetry/OriginAllowList.ts +355 -0
- package/Utils/Uptime/DayUptimeGraphUtil.ts +205 -0
- package/Utils/ValueFormatter.ts +35 -1
- package/build/dist/Models/AnalyticsModels/RumSession.js +102 -19
- package/build/dist/Models/AnalyticsModels/RumSession.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/RumSessionChunk.js +11 -9
- package/build/dist/Models/AnalyticsModels/RumSessionChunk.js.map +1 -1
- package/build/dist/Models/DatabaseModels/AlertCustomField.js +79 -3
- package/build/dist/Models/DatabaseModels/AlertCustomField.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ApiKeyPermission.js +3 -27
- package/build/dist/Models/DatabaseModels/ApiKeyPermission.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentCustomField.js +79 -3
- package/build/dist/Models/DatabaseModels/IncidentCustomField.js.map +1 -1
- package/build/dist/Models/DatabaseModels/InventoryItemCustomField.js +79 -3
- package/build/dist/Models/DatabaseModels/InventoryItemCustomField.js.map +1 -1
- package/build/dist/Models/DatabaseModels/MonitorCustomField.js +79 -3
- package/build/dist/Models/DatabaseModels/MonitorCustomField.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyCustomField.js +79 -3
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyCustomField.js.map +1 -1
- package/build/dist/Models/DatabaseModels/RumApplication.js +6 -6
- package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ScheduledMaintenanceCustomField.js +79 -3
- package/build/dist/Models/DatabaseModels/ScheduledMaintenanceCustomField.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPageCustomField.js +79 -3
- package/build/dist/Models/DatabaseModels/StatusPageCustomField.js.map +1 -1
- package/build/dist/Models/DatabaseModels/TeamCustomField.js +79 -3
- package/build/dist/Models/DatabaseModels/TeamCustomField.js.map +1 -1
- package/build/dist/Models/DatabaseModels/TeamMember.js +0 -9
- package/build/dist/Models/DatabaseModels/TeamMember.js.map +1 -1
- package/build/dist/Models/DatabaseModels/TeamMemberCustomField.js +79 -3
- package/build/dist/Models/DatabaseModels/TeamMemberCustomField.js.map +1 -1
- package/build/dist/Models/DatabaseModels/TeamPermission.js +3 -24
- package/build/dist/Models/DatabaseModels/TeamPermission.js.map +1 -1
- package/build/dist/Models/DatabaseModels/TelemetryIngestionKey.js +267 -0
- package/build/dist/Models/DatabaseModels/TelemetryIngestionKey.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserTelegram.js +0 -4
- package/build/dist/Models/DatabaseModels/UserTelegram.js.map +1 -1
- package/build/dist/Server/API/TelemetryAPI.js +769 -109
- package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
- package/build/dist/Server/API/UserNotificationSettingAPI.js +35 -0
- package/build/dist/Server/API/UserNotificationSettingAPI.js.map +1 -0
- package/build/dist/Server/API/UserTelegramAPI.js +28 -6
- package/build/dist/Server/API/UserTelegramAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +29 -2
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1791300000000-AddTelemetryIngestionKeyType.js +60 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1791300000000-AddTelemetryIngestionKeyType.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1791400000000-SessionReplayRecordEverySessionByDefault.js +53 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1791400000000-SessionReplayRecordEverySessionByDefault.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1791500000000-WidenCustomFieldDropdownOptions.js +82 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1791500000000-WidenCustomFieldDropdownOptions.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1791600000000-AddCustomFieldValueMapping.js +46 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1791600000000-AddCustomFieldValueMapping.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Infrastructure/QueueWorker.js +19 -7
- package/build/dist/Server/Infrastructure/QueueWorker.js.map +1 -1
- package/build/dist/Server/Infrastructure/Semaphore.js +1 -0
- package/build/dist/Server/Infrastructure/Semaphore.js.map +1 -1
- package/build/dist/Server/Middleware/TelemetryIngest.js +315 -8
- package/build/dist/Server/Middleware/TelemetryIngest.js.map +1 -1
- package/build/dist/Server/Services/AccessTokenService.js +1 -0
- package/build/dist/Server/Services/AccessTokenService.js.map +1 -1
- package/build/dist/Server/Services/AlertCustomFieldService.js +93 -0
- package/build/dist/Server/Services/AlertCustomFieldService.js.map +1 -1
- package/build/dist/Server/Services/AlertService.js +34 -0
- package/build/dist/Server/Services/AlertService.js.map +1 -1
- package/build/dist/Server/Services/ApiKeyPermissionService.js +331 -28
- package/build/dist/Server/Services/ApiKeyPermissionService.js.map +1 -1
- package/build/dist/Server/Services/CustomFieldMappingService.js +614 -0
- package/build/dist/Server/Services/CustomFieldMappingService.js.map +1 -0
- package/build/dist/Server/Services/GlobalConfigService.js +133 -0
- package/build/dist/Server/Services/GlobalConfigService.js.map +1 -1
- package/build/dist/Server/Services/IncidentCustomFieldService.js +93 -0
- package/build/dist/Server/Services/IncidentCustomFieldService.js.map +1 -1
- package/build/dist/Server/Services/IncidentService.js +32 -0
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/MetricService.js +163 -0
- package/build/dist/Server/Services/MetricService.js.map +1 -1
- package/build/dist/Server/Services/MonitorService.js +29 -0
- package/build/dist/Server/Services/MonitorService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js +79 -27
- package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ProjectService.js +22 -0
- package/build/dist/Server/Services/ProjectService.js.map +1 -1
- package/build/dist/Server/Services/RoutineEmailSettingsService.js +69 -0
- package/build/dist/Server/Services/RoutineEmailSettingsService.js.map +1 -0
- package/build/dist/Server/Services/RumSessionReplayViewService.js +86 -24
- package/build/dist/Server/Services/RumSessionReplayViewService.js.map +1 -1
- package/build/dist/Server/Services/ScheduledMaintenanceCustomFieldService.js +93 -0
- package/build/dist/Server/Services/ScheduledMaintenanceCustomFieldService.js.map +1 -1
- package/build/dist/Server/Services/ScheduledMaintenanceService.js +29 -0
- package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
- package/build/dist/Server/Services/TeamMemberService.js +62 -2
- package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
- package/build/dist/Server/Services/TeamPermissionService.js +166 -3
- package/build/dist/Server/Services/TeamPermissionService.js.map +1 -1
- package/build/dist/Server/Services/TelemetryIngestionKeyService.js +562 -18
- package/build/dist/Server/Services/TelemetryIngestionKeyService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationSettingService.js +26 -6
- package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
- package/build/dist/Server/Services/UserTelegramService.js +257 -4
- package/build/dist/Server/Services/UserTelegramService.js.map +1 -1
- package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
- package/build/dist/Server/Utils/APIKey/AccessPermission.js +2 -2
- package/build/dist/Server/Utils/APIKey/AccessPermission.js.map +1 -1
- package/build/dist/Server/Utils/CustomField/CustomFieldDefinitionMappingHooks.js +27 -0
- package/build/dist/Server/Utils/CustomField/CustomFieldDefinitionMappingHooks.js.map +1 -0
- package/build/dist/Server/Utils/CustomField/CustomFieldMappingRegistry.js +226 -0
- package/build/dist/Server/Utils/CustomField/CustomFieldMappingRegistry.js.map +1 -0
- package/build/dist/Server/Utils/CustomField/CustomFieldMappingValidator.js +189 -0
- package/build/dist/Server/Utils/CustomField/CustomFieldMappingValidator.js.map +1 -0
- package/build/dist/Server/Utils/DataSource/EgressGuard.js +121 -7
- package/build/dist/Server/Utils/DataSource/EgressGuard.js.map +1 -1
- package/build/dist/Server/Utils/EmailRollup/EmailRollupConstants.js +38 -5
- package/build/dist/Server/Utils/EmailRollup/EmailRollupConstants.js.map +1 -1
- package/build/dist/Server/Utils/EmailRollup/EmailRollupFlushRunner.js +44 -2
- package/build/dist/Server/Utils/EmailRollup/EmailRollupFlushRunner.js.map +1 -1
- package/build/dist/Server/Utils/FrontendEnvironment.js +33 -0
- package/build/dist/Server/Utils/FrontendEnvironment.js.map +1 -0
- package/build/dist/Server/Utils/LogRedaction.js +20 -0
- package/build/dist/Server/Utils/LogRedaction.js.map +1 -1
- package/build/dist/Server/Utils/Logger.js +10 -0
- package/build/dist/Server/Utils/Logger.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +287 -68
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js +20 -4
- package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorAlert.js +29 -6
- package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +88 -13
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorIncident.js +22 -6
- package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorTemplateUtil.js +22 -0
- package/build/dist/Server/Utils/Monitor/MonitorTemplateUtil.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/SeriesContextEnricher.js +237 -0
- package/build/dist/Server/Utils/Monitor/SeriesContextEnricher.js.map +1 -0
- package/build/dist/Server/Utils/SSRFProtection.js +136 -9
- package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js +174 -89
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayHealthCounters.js +223 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayHealthCounters.js.map +1 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayReadService.js +945 -164
- package/build/dist/Server/Utils/SessionReplay/SessionReplayReadService.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +3 -14
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/TelegramVerificationToken.js +129 -0
- package/build/dist/Server/Utils/TelegramVerificationToken.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/PinServiceName.js +163 -0
- package/build/dist/Server/Utils/Telemetry/PinServiceName.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryFanInWriter.js +28 -15
- package/build/dist/Server/Utils/Telemetry/TelemetryFanInWriter.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.js +64 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryIngestionKeyGuard.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryIngestionKeyRateLimiter.js +171 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryIngestionKeyRateLimiter.js.map +1 -0
- package/build/dist/Server/Utils/VM/VMAPI.js.map +1 -1
- package/build/dist/Server/Utils/VM/VMRunner.js +780 -69
- package/build/dist/Server/Utils/VM/VMRunner.js.map +1 -1
- package/build/dist/Types/CustomField/CustomFieldMappingCatalog.js +60 -0
- package/build/dist/Types/CustomField/CustomFieldMappingCatalog.js.map +1 -0
- package/build/dist/Types/CustomField/CustomFieldMappingSourceResource.js +20 -0
- package/build/dist/Types/CustomField/CustomFieldMappingSourceResource.js.map +1 -0
- package/build/dist/Types/CustomField/CustomFieldValueMapping.js +141 -0
- package/build/dist/Types/CustomField/CustomFieldValueMapping.js.map +1 -0
- package/build/dist/Types/Icon/IconProp.js +1 -0
- package/build/dist/Types/Icon/IconProp.js.map +1 -1
- package/build/dist/Types/JSONFunctions.js +118 -37
- package/build/dist/Types/JSONFunctions.js.map +1 -1
- package/build/dist/Types/Monitor/CephAlertTemplates.js +130 -171
- package/build/dist/Types/Monitor/CephAlertTemplates.js.map +1 -1
- package/build/dist/Types/Monitor/DockerAlertTemplates.js +286 -121
- package/build/dist/Types/Monitor/DockerAlertTemplates.js.map +1 -1
- package/build/dist/Types/Monitor/DockerSwarmAlertTemplates.js +84 -105
- package/build/dist/Types/Monitor/DockerSwarmAlertTemplates.js.map +1 -1
- package/build/dist/Types/Monitor/HostAlertTemplates.js +250 -122
- package/build/dist/Types/Monitor/HostAlertTemplates.js.map +1 -1
- package/build/dist/Types/Monitor/IotAlertTemplates.js +81 -81
- package/build/dist/Types/Monitor/IotAlertTemplates.js.map +1 -1
- package/build/dist/Types/Monitor/KubernetesAlertTemplates.js +375 -215
- package/build/dist/Types/Monitor/KubernetesAlertTemplates.js.map +1 -1
- package/build/dist/Types/Monitor/KubernetesMetricCatalog.js +19 -19
- package/build/dist/Types/Monitor/KubernetesMetricCatalog.js.map +1 -1
- package/build/dist/Types/Monitor/PodmanAlertTemplates.js +104 -153
- package/build/dist/Types/Monitor/PodmanAlertTemplates.js.map +1 -1
- package/build/dist/Types/Monitor/ProxmoxAlertTemplates.js +156 -91
- package/build/dist/Types/Monitor/ProxmoxAlertTemplates.js.map +1 -1
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationSeverityMapper.js +67 -6
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationSeverityMapper.js.map +1 -1
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationUtil.js +38 -7
- package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationUtil.js.map +1 -1
- package/build/dist/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.js +261 -0
- package/build/dist/Types/Monitor/Recommendation/RecommendationCriteriaBuilder.js.map +1 -0
- package/build/dist/Types/Monitor/RumAlertTemplates.js +46 -7
- package/build/dist/Types/Monitor/RumAlertTemplates.js.map +1 -1
- package/build/dist/Types/Monitor/SeriesContext/SeriesDebugHints.js +422 -0
- package/build/dist/Types/Monitor/SeriesContext/SeriesDebugHints.js.map +1 -0
- package/build/dist/Types/Monitor/SeriesContext/SeriesLabelDisplay.js +441 -0
- package/build/dist/Types/Monitor/SeriesContext/SeriesLabelDisplay.js.map +1 -0
- package/build/dist/Types/Monitor/ServiceAlertTemplates.js +79 -15
- package/build/dist/Types/Monitor/ServiceAlertTemplates.js.map +1 -1
- package/build/dist/Types/Monitor/UptimeHistoryLabels.js +17 -0
- package/build/dist/Types/Monitor/UptimeHistoryLabels.js.map +1 -0
- package/build/dist/Types/NotificationSetting/RoutineEmailEvents.js +29 -0
- package/build/dist/Types/NotificationSetting/RoutineEmailEvents.js.map +1 -0
- package/build/dist/Types/Rum/SessionReplay.js +117 -0
- package/build/dist/Types/Rum/SessionReplay.js.map +1 -1
- package/build/dist/Types/Rum/SessionReplayApi.js +181 -0
- package/build/dist/Types/Rum/SessionReplayApi.js.map +1 -0
- package/build/dist/Types/Rum/SessionReplayCaptureTrigger.js +21 -12
- package/build/dist/Types/Rum/SessionReplayCaptureTrigger.js.map +1 -1
- package/build/dist/Types/Rum/SessionReplayConsentMode.js +11 -7
- package/build/dist/Types/Rum/SessionReplayConsentMode.js.map +1 -1
- package/build/dist/Types/Rum/SessionReplayCustomEvents.js +172 -0
- package/build/dist/Types/Rum/SessionReplayCustomEvents.js.map +1 -0
- package/build/dist/Types/Rum/SessionReplayHealth.js +37 -0
- package/build/dist/Types/Rum/SessionReplayHealth.js.map +1 -0
- package/build/dist/Types/Telemetry/TelemetryIngestSurface.js +85 -0
- package/build/dist/Types/Telemetry/TelemetryIngestSurface.js.map +1 -0
- package/build/dist/Types/Telemetry/TelemetryIngestionKeyPolicy.js +15 -0
- package/build/dist/Types/Telemetry/TelemetryIngestionKeyPolicy.js.map +1 -0
- package/build/dist/Types/Telemetry/TelemetryIngestionKeyType.js +30 -0
- package/build/dist/Types/Telemetry/TelemetryIngestionKeyType.js.map +1 -0
- package/build/dist/Types/WebsiteRequest.js +55 -3
- package/build/dist/Types/WebsiteRequest.js.map +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js +13 -2
- package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js +6 -2
- package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js +13 -2
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Types/XAxis/XAxis.js.map +1 -1
- package/build/dist/UI/Components/Charts/Utils/DataPoint.js +15 -3
- package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
- package/build/dist/UI/Components/Charts/Utils/TimeAnnotation.js +40 -9
- package/build/dist/UI/Components/Charts/Utils/TimeAnnotation.js.map +1 -1
- package/build/dist/UI/Components/Charts/Utils/XAxis.js +186 -0
- package/build/dist/UI/Components/Charts/Utils/XAxis.js.map +1 -1
- package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +80 -6
- package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
- package/build/dist/UI/Components/CustomFields/MapFromCustomFieldInput.js +118 -0
- package/build/dist/UI/Components/CustomFields/MapFromCustomFieldInput.js.map +1 -0
- package/build/dist/UI/Components/Graphs/DayUptimeGraph.js +112 -7
- package/build/dist/UI/Components/Graphs/DayUptimeGraph.js.map +1 -1
- package/build/dist/UI/Components/Graphs/UptimeBarTooltip.js +5 -162
- package/build/dist/UI/Components/Graphs/UptimeBarTooltip.js.map +1 -1
- package/build/dist/UI/Components/Graphs/UptimeDaySummary.js +181 -0
- package/build/dist/UI/Components/Graphs/UptimeDaySummary.js.map +1 -0
- package/build/dist/UI/Components/Header/Header.js +2 -3
- package/build/dist/UI/Components/Header/Header.js.map +1 -1
- package/build/dist/UI/Components/Header/IconDropdown/IconDropdownItem.js +2 -1
- package/build/dist/UI/Components/Header/IconDropdown/IconDropdownItem.js.map +1 -1
- package/build/dist/UI/Components/Header/IconDropdown/IconDropdownMenu.js +7 -1
- package/build/dist/UI/Components/Header/IconDropdown/IconDropdownMenu.js.map +1 -1
- package/build/dist/UI/Components/Header/ProjectPicker/ProjectPicker.js +7 -1
- package/build/dist/UI/Components/Header/ProjectPicker/ProjectPicker.js.map +1 -1
- package/build/dist/UI/Components/HeaderAlert/NotificationBell/NotificationBellDropdown.js +7 -1
- package/build/dist/UI/Components/HeaderAlert/NotificationBell/NotificationBellDropdown.js.map +1 -1
- package/build/dist/UI/Components/Icon/Icon.js +5 -0
- package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
- package/build/dist/UI/Components/JSONTable/JSONTable.js +2 -2
- package/build/dist/UI/Components/JSONTable/JSONTable.js.map +1 -1
- package/build/dist/UI/Components/KeyboardShortcut/KeyboardShortcutsModal.js +35 -0
- package/build/dist/UI/Components/KeyboardShortcut/KeyboardShortcutsModal.js.map +1 -0
- package/build/dist/UI/Components/Monitor/SeriesDebugCommandsViewer.js +34 -0
- package/build/dist/UI/Components/Monitor/SeriesDebugCommandsViewer.js.map +1 -0
- package/build/dist/UI/Components/Monitor/SeriesLabelsViewer.js +49 -0
- package/build/dist/UI/Components/Monitor/SeriesLabelsViewer.js.map +1 -0
- package/build/dist/UI/Components/MonitorGraphs/Uptime.js +1 -1
- package/build/dist/UI/Components/MonitorGraphs/Uptime.js.map +1 -1
- package/build/dist/UI/Components/MonitorGraphs/UptimeBarDayModal.js +32 -6
- package/build/dist/UI/Components/MonitorGraphs/UptimeBarDayModal.js.map +1 -1
- package/build/dist/UI/Components/MonitorTemplateVariables/TemplateVariablesCatalog.js +38 -0
- package/build/dist/UI/Components/MonitorTemplateVariables/TemplateVariablesCatalog.js.map +1 -1
- package/build/dist/UI/Components/StatusPage/ResourceGroupSection.js +22 -1
- package/build/dist/UI/Components/StatusPage/ResourceGroupSection.js.map +1 -1
- package/build/dist/UI/Components/Table/CellClassName.js +55 -0
- package/build/dist/UI/Components/Table/CellClassName.js.map +1 -0
- package/build/dist/UI/Components/Table/TableRow.js +16 -13
- package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
- package/build/dist/UI/Components/Table/TableSkeletonRows.js +9 -8
- package/build/dist/UI/Components/Table/TableSkeletonRows.js.map +1 -1
- package/build/dist/UI/Components/Tooltip/Tooltip.js +25 -1
- package/build/dist/UI/Components/Tooltip/Tooltip.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentsModal.js +47 -30
- package/build/dist/UI/Components/Workflow/ComponentsModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/NodePlacement.js +37 -0
- package/build/dist/UI/Components/Workflow/NodePlacement.js.map +1 -0
- package/build/dist/UI/Components/Workflow/Workflow.js +35 -18
- package/build/dist/UI/Components/Workflow/Workflow.js.map +1 -1
- package/build/dist/UI/Config.js +10 -20
- package/build/dist/UI/Config.js.map +1 -1
- package/build/dist/UI/Utils/GlobalKeyboardShortcut.js +138 -0
- package/build/dist/UI/Utils/GlobalKeyboardShortcut.js.map +1 -0
- package/build/dist/UI/Utils/Telemetry/BrowserTelemetryConfig.js +14 -0
- package/build/dist/UI/Utils/Telemetry/BrowserTelemetryConfig.js.map +1 -0
- package/build/dist/UI/Utils/Telemetry/Telemetry.js +7 -5
- package/build/dist/UI/Utils/Telemetry/Telemetry.js.map +1 -1
- package/build/dist/Utils/API.js +67 -5
- package/build/dist/Utils/API.js.map +1 -1
- package/build/dist/Utils/HTTPResponseBodyReader.js +157 -0
- package/build/dist/Utils/HTTPResponseBodyReader.js.map +1 -0
- package/build/dist/Utils/NetworkDiscovery/DiscoveryScanStatus.js +69 -0
- package/build/dist/Utils/NetworkDiscovery/DiscoveryScanStatus.js.map +1 -0
- package/build/dist/Utils/Rum/ChunkMath.js +76 -0
- package/build/dist/Utils/Rum/ChunkMath.js.map +1 -1
- package/build/dist/Utils/Rum/SessionReplayHealth.js +534 -0
- package/build/dist/Utils/Rum/SessionReplayHealth.js.map +1 -0
- package/build/dist/Utils/Rum/SessionReplayStringMap.js +165 -0
- package/build/dist/Utils/Rum/SessionReplayStringMap.js.map +1 -0
- package/build/dist/Utils/Schema/ModelSchema.js +1 -0
- package/build/dist/Utils/Schema/ModelSchema.js.map +1 -1
- package/build/dist/Utils/StatusPage/ResourceSearch.js +200 -0
- package/build/dist/Utils/StatusPage/ResourceSearch.js.map +1 -0
- package/build/dist/Utils/Telemetry/OriginAllowList.js +299 -0
- package/build/dist/Utils/Telemetry/OriginAllowList.js.map +1 -0
- package/build/dist/Utils/Uptime/DayUptimeGraphUtil.js +147 -0
- package/build/dist/Utils/Uptime/DayUptimeGraphUtil.js.map +1 -0
- package/build/dist/Utils/ValueFormatter.js +32 -1
- package/build/dist/Utils/ValueFormatter.js.map +1 -1
- package/package.json +1 -1
|
@@ -2,22 +2,35 @@ import { SQL, Statement } from "../AnalyticsDatabase/Statement";
|
|
|
2
2
|
import { getQuerySettings } from "../AnalyticsDatabase/QuerySettingsHelper";
|
|
3
3
|
import RumSessionService from "../../Services/RumSessionService";
|
|
4
4
|
import RumSessionChunkService from "../../Services/RumSessionChunkService";
|
|
5
|
+
import ExceptionInstanceService from "../../Services/ExceptionInstanceService";
|
|
5
6
|
import {
|
|
6
7
|
DbJSONResponse,
|
|
7
8
|
Results,
|
|
8
9
|
} from "../../Services/AnalyticsDatabaseService";
|
|
10
|
+
import logger from "../Logger";
|
|
9
11
|
import AnalyticsTableName from "../../../Types/AnalyticsDatabase/AnalyticsTableName";
|
|
10
12
|
import TableColumnType from "../../../Types/AnalyticsDatabase/TableColumnType";
|
|
11
13
|
import Includes from "../../../Types/BaseDatabase/Includes";
|
|
12
14
|
import { JSONObject } from "../../../Types/JSON";
|
|
13
15
|
import ObjectID from "../../../Types/ObjectID";
|
|
16
|
+
import OneUptimeDate from "../../../Types/Date";
|
|
14
17
|
import ChunkMath from "../../../Utils/Rum/ChunkMath";
|
|
15
18
|
import {
|
|
16
19
|
MAX_SESSION_REPLAY_CHUNKS_PER_READ,
|
|
17
20
|
MAX_SESSION_REPLAY_READ_BYTES,
|
|
21
|
+
SESSION_REPLAY_LIST_SEARCH_MAX_LENGTH,
|
|
22
|
+
SESSION_REPLAY_MAX_SESSION_MS,
|
|
23
|
+
SESSION_REPLAY_MAX_TAG_KEYS,
|
|
24
|
+
SESSION_REPLAY_RECORDER_CAPABILITIES,
|
|
18
25
|
SessionReplayChunkManifestEntry,
|
|
19
26
|
SessionReplayGap,
|
|
27
|
+
SessionReplaySealedReason,
|
|
20
28
|
} from "../../../Types/Rum/SessionReplay";
|
|
29
|
+
import {
|
|
30
|
+
SESSION_REPLAY_SORT_BY_VALUES,
|
|
31
|
+
SessionReplaySortBy,
|
|
32
|
+
SessionReplaySortedListCursorDto,
|
|
33
|
+
} from "../../../Types/Rum/SessionReplayApi";
|
|
21
34
|
import BadDataException from "../../../Types/Exception/BadDataException";
|
|
22
35
|
import CaptureSpan from "../Telemetry/CaptureSpan";
|
|
23
36
|
|
|
@@ -50,10 +63,9 @@ import CaptureSpan from "../Telemetry/CaptureSpan";
|
|
|
50
63
|
* 3. The manifest read must never name the `payload` column, so
|
|
51
64
|
* ClickHouse never touches (and never decompresses) the only column
|
|
52
65
|
* in the system that holds a recording of a real person's screen.
|
|
53
|
-
*
|
|
54
|
-
* `length(payload)`
|
|
55
|
-
*
|
|
56
|
-
* and the only honest measure of that is the stored column itself.
|
|
66
|
+
* getChunks is the one read that names it, and it measures
|
|
67
|
+
* `length(payload)` in the same statement that ships the bytes, so
|
|
68
|
+
* the column is decompressed exactly once per page.
|
|
57
69
|
*
|
|
58
70
|
* NOTE on aliases: ClickHouse substitutes SELECT aliases into same-level
|
|
59
71
|
* unqualified WHERE references, and an aggregate alias there is an
|
|
@@ -84,6 +96,29 @@ export const MAX_SESSION_REPLAY_LIST_LIMIT: number = 200;
|
|
|
84
96
|
/* Sessions returned by the exception -> replay lookup. */
|
|
85
97
|
export const MAX_SESSION_REPLAY_FOR_EXCEPTION_LIMIT: number = 20;
|
|
86
98
|
|
|
99
|
+
/*
|
|
100
|
+
* Default window for the exception -> replay lookup when the caller gives
|
|
101
|
+
* none. RumSession is partitioned by day, so an unbounded lookup scans
|
|
102
|
+
* every partition the project has ever written; 30 days covers every
|
|
103
|
+
* retention tier a recording can still be played under.
|
|
104
|
+
*/
|
|
105
|
+
export const DEFAULT_SESSION_REPLAY_FOR_EXCEPTION_WINDOW_DAYS: number = 30;
|
|
106
|
+
|
|
107
|
+
/*
|
|
108
|
+
* Sessions the exception-instance side index may name. The instance table
|
|
109
|
+
* carries the session id of the page that threw, which is how a session
|
|
110
|
+
* is found BEFORE the finalizer has written its fingerprint list.
|
|
111
|
+
*/
|
|
112
|
+
const MAX_EXCEPTION_INSTANCE_SESSION_IDS: number = 100;
|
|
113
|
+
|
|
114
|
+
/*
|
|
115
|
+
* Padding around an exception's own timestamp when the caller pins the
|
|
116
|
+
* lookup to a moment: a session that contains the error started at most
|
|
117
|
+
* SESSION_REPLAY_MAX_SESSION_MS before it, and clock skew between the
|
|
118
|
+
* browser and the server is bounded far below this.
|
|
119
|
+
*/
|
|
120
|
+
export const SESSION_REPLAY_EXCEPTION_WINDOW_PADDING_MS: number = 5 * 60 * 1000;
|
|
121
|
+
|
|
87
122
|
/*
|
|
88
123
|
* Row ceiling on one manifest. A session is capped at
|
|
89
124
|
* MAX_SESSION_REPLAY_CHUNKS_PER_SESSION (480) chunks PER TAB, and a
|
|
@@ -93,11 +128,24 @@ export const MAX_SESSION_REPLAY_FOR_EXCEPTION_LIMIT: number = 20;
|
|
|
93
128
|
*/
|
|
94
129
|
const MAX_MANIFEST_ROWS: number = 4096;
|
|
95
130
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
131
|
+
/*
|
|
132
|
+
* How long one application's activity summary is served from memory. The
|
|
133
|
+
* health card polls every 10-60s per viewer and the summary is a small
|
|
134
|
+
* aggregate over a day of headers, so a 30s cache turns N viewers into
|
|
135
|
+
* one ClickHouse query per pod per half minute.
|
|
136
|
+
*/
|
|
137
|
+
export const SESSION_REPLAY_ACTIVITY_SUMMARY_CACHE_TTL_MS: number = 30 * 1000;
|
|
138
|
+
const MAX_ACTIVITY_SUMMARY_CACHE_ENTRIES: number = 1000;
|
|
139
|
+
|
|
140
|
+
/* The header attribute the ingest writes chunk 0's capability list into. */
|
|
141
|
+
export const RECORDER_CAPABILITIES_ATTRIBUTE: string = "recorder.capabilities";
|
|
142
|
+
|
|
143
|
+
/*
|
|
144
|
+
* The keyset cursor the list accepts and emits. The legacy
|
|
145
|
+
* {startTimeUnixMs, sessionId} shape is normalised to this by
|
|
146
|
+
* parseSessionReplayListCursor before it reaches the service.
|
|
147
|
+
*/
|
|
148
|
+
export type SessionReplayListCursor = SessionReplaySortedListCursorDto;
|
|
101
149
|
|
|
102
150
|
export interface SessionReplayListFilters {
|
|
103
151
|
hasError?: boolean | undefined;
|
|
@@ -118,6 +166,20 @@ export interface SessionReplayListFilters {
|
|
|
118
166
|
/* "sessions that hit /checkout" - matches the routes array. */
|
|
119
167
|
route?: string | undefined;
|
|
120
168
|
minDurationMs?: number | undefined;
|
|
169
|
+
/*
|
|
170
|
+
* Free text: sessionId prefix, entry/exit URL and routes substring, exact
|
|
171
|
+
* trace id, and the identified user label when the caller may read it.
|
|
172
|
+
* Capped at SESSION_REPLAY_LIST_SEARCH_MAX_LENGTH by the handler.
|
|
173
|
+
*/
|
|
174
|
+
search?: string | undefined;
|
|
175
|
+
/* startsWith over the routes array and the entry URL. */
|
|
176
|
+
urlPrefix?: string | undefined;
|
|
177
|
+
/* Every pair must match the session's tag map. */
|
|
178
|
+
tags?: Record<string, string> | undefined;
|
|
179
|
+
hasIdentifiedUser?: boolean | undefined;
|
|
180
|
+
/* (not finalized OR has chunks) AND not recording-lost. */
|
|
181
|
+
isPlayable?: boolean | undefined;
|
|
182
|
+
hasTraces?: boolean | undefined;
|
|
121
183
|
}
|
|
122
184
|
|
|
123
185
|
export interface SessionReplayListRequest {
|
|
@@ -128,11 +190,15 @@ export interface SessionReplayListRequest {
|
|
|
128
190
|
filters: SessionReplayListFilters;
|
|
129
191
|
limit: number;
|
|
130
192
|
cursor?: SessionReplayListCursor | undefined;
|
|
193
|
+
/* Absent means "startTime", which is what the list always did. */
|
|
194
|
+
sortBy?: SessionReplaySortBy | undefined;
|
|
131
195
|
/*
|
|
132
196
|
* The raw end-user identifier has its own, narrower column ACL than the
|
|
133
197
|
* rest of the header row. This raw-SQL path never invokes
|
|
134
198
|
* ModelPermission, so the caller decides column-by-column and the
|
|
135
199
|
* column is simply not named in the SELECT when it is not permitted.
|
|
200
|
+
* Gates the traits column and the label half of the search predicate
|
|
201
|
+
* as well.
|
|
136
202
|
*/
|
|
137
203
|
includeIdentifiedUserLabel: boolean;
|
|
138
204
|
}
|
|
@@ -170,7 +236,24 @@ export interface SessionReplayListItem {
|
|
|
170
236
|
identifiedUserKey: string;
|
|
171
237
|
/* Present only when the caller holds the narrower identity permission. */
|
|
172
238
|
identifiedUserLabel?: string | undefined;
|
|
239
|
+
identifiedUserTraits?: Record<string, string> | undefined;
|
|
173
240
|
samplePercentageAtCapture: number;
|
|
241
|
+
/* First MAX_LIST_ROUTES routes, in order. */
|
|
242
|
+
routes: Array<string>;
|
|
243
|
+
traceCount: number;
|
|
244
|
+
exceptionGroupCount: number;
|
|
245
|
+
/*
|
|
246
|
+
* The first exception fingerprint of the session, "" when there is none.
|
|
247
|
+
* The list's errors badge links at the exception group with it.
|
|
248
|
+
*/
|
|
249
|
+
topExceptionFingerprint: string;
|
|
250
|
+
clickCount: number;
|
|
251
|
+
activeMs: number;
|
|
252
|
+
firstErrorOffsetMs: number;
|
|
253
|
+
expiresAtUnixMs: number;
|
|
254
|
+
tags: Record<string, string>;
|
|
255
|
+
startTimeUnixMs: number;
|
|
256
|
+
endTimeUnixMs: number;
|
|
174
257
|
}
|
|
175
258
|
|
|
176
259
|
export interface SessionReplayListResult {
|
|
@@ -178,6 +261,9 @@ export interface SessionReplayListResult {
|
|
|
178
261
|
nextCursor: SessionReplayListCursor | null;
|
|
179
262
|
}
|
|
180
263
|
|
|
264
|
+
/* Routes projected onto a list row; the table shows three and says "(N pages)". */
|
|
265
|
+
export const MAX_LIST_ROUTES: number = 5;
|
|
266
|
+
|
|
181
267
|
export interface SessionReplaySessionHeader {
|
|
182
268
|
sessionId: string;
|
|
183
269
|
projectId: string;
|
|
@@ -222,6 +308,50 @@ export interface SessionReplaySessionHeader {
|
|
|
222
308
|
traceIds: Array<string>;
|
|
223
309
|
exceptionFingerprints: Array<string>;
|
|
224
310
|
clockSkewMs: number;
|
|
311
|
+
/*
|
|
312
|
+
* The session clock as numbers, so the player places every telemetry
|
|
313
|
+
* row at rowUnixMs - startTimeUnixMs without re-parsing an ISO string.
|
|
314
|
+
*/
|
|
315
|
+
startTimeUnixMs: number;
|
|
316
|
+
endTimeUnixMs: number;
|
|
317
|
+
/* The recorder's own start clock, before the server clamped it. */
|
|
318
|
+
clientReportedStartUnixMs: number;
|
|
319
|
+
tags: Record<string, string>;
|
|
320
|
+
expiresAtUnixMs: number;
|
|
321
|
+
clickCount: number;
|
|
322
|
+
customEventCount: number;
|
|
323
|
+
activeMs: number;
|
|
324
|
+
firstErrorOffsetMs: number;
|
|
325
|
+
/*
|
|
326
|
+
* From chunk 0's envelope (attributes["recorder.capabilities"]); empty
|
|
327
|
+
* for recordings that predate the field.
|
|
328
|
+
*/
|
|
329
|
+
recorderCapabilities: Array<string>;
|
|
330
|
+
/*
|
|
331
|
+
* Never populated by getSessionHeader. The manifest handler fills them
|
|
332
|
+
* from getSessionIdentity ONLY after canReadIdentifiedUserLabel passes,
|
|
333
|
+
* so no statement names the identity columns for a caller who may not
|
|
334
|
+
* read them.
|
|
335
|
+
*/
|
|
336
|
+
identifiedUserLabel?: string | undefined;
|
|
337
|
+
identifiedUserTraits?: Record<string, string> | undefined;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/* The two identity columns, read separately behind the identity ACL. */
|
|
341
|
+
export interface SessionReplaySessionIdentity {
|
|
342
|
+
identifiedUserLabel: string;
|
|
343
|
+
identifiedUserTraits: Record<string, string>;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/*
|
|
347
|
+
* What is still knowable about a session whose header has aged out of
|
|
348
|
+
* retention (or was never finalized), for the "this recording expired on
|
|
349
|
+
* <date>" answer instead of a bare "not found".
|
|
350
|
+
*/
|
|
351
|
+
export interface SessionReplayExpiredSessionInfo {
|
|
352
|
+
rumApplicationId: string;
|
|
353
|
+
startTime: Date;
|
|
354
|
+
expiresAt: Date;
|
|
225
355
|
}
|
|
226
356
|
|
|
227
357
|
/*
|
|
@@ -238,6 +368,8 @@ export interface SessionReplayManifestTab {
|
|
|
238
368
|
gaps: Array<SessionReplayGap>;
|
|
239
369
|
maxChunkIndex: number;
|
|
240
370
|
totalPayloadBytes: number;
|
|
371
|
+
/* Where this tab's footage begins on the session clock. */
|
|
372
|
+
firstChunkStartOffsetMs: number;
|
|
241
373
|
}
|
|
242
374
|
|
|
243
375
|
export interface SessionReplayManifest {
|
|
@@ -256,6 +388,17 @@ export interface SessionReplayChunkPayload {
|
|
|
256
388
|
payload: string;
|
|
257
389
|
}
|
|
258
390
|
|
|
391
|
+
export interface SessionReplayChunkReadResult {
|
|
392
|
+
/* The longest contiguous prefix of the requested chunks under the cap. */
|
|
393
|
+
chunks: Array<SessionReplayChunkPayload>;
|
|
394
|
+
/*
|
|
395
|
+
* Chunks that exist and were requested but did not fit under
|
|
396
|
+
* MAX_SESSION_REPLAY_READ_BYTES behind the ones served. A chunk absent
|
|
397
|
+
* from storage is NOT listed here: that is a gap, not an omission.
|
|
398
|
+
*/
|
|
399
|
+
omittedChunkIndexes: Array<number>;
|
|
400
|
+
}
|
|
401
|
+
|
|
259
402
|
export interface SessionReplayExceptionSession {
|
|
260
403
|
sessionId: string;
|
|
261
404
|
rumApplicationId: string;
|
|
@@ -277,6 +420,26 @@ export interface SessionReplayExceptionSession {
|
|
|
277
420
|
isFinalized: boolean;
|
|
278
421
|
}
|
|
279
422
|
|
|
423
|
+
export interface SessionReplayApplicationActivitySummary {
|
|
424
|
+
/* null when ClickHouse could not answer; the UI renders "unknown". */
|
|
425
|
+
sessionsLast24h: number | null;
|
|
426
|
+
playableSessionsLast24h: number | null;
|
|
427
|
+
/* null when the application has no session in retention. */
|
|
428
|
+
lastSessionStartedAt: Date | null;
|
|
429
|
+
/*
|
|
430
|
+
* What the NEWEST session's recorder said it could capture, filtered to
|
|
431
|
+
* the known vocabulary. null when there is no session in retention, when
|
|
432
|
+
* that session predates the attribute, or when the query failed - all
|
|
433
|
+
* three render as "not reported yet", which is the honest answer.
|
|
434
|
+
*
|
|
435
|
+
* This is how an operator spots a stale cached recorder artifact
|
|
436
|
+
* ("click labels: no") without opening a recording, which would write an
|
|
437
|
+
* audit row. It rides on the last-session query that is already run for
|
|
438
|
+
* lastSessionStartedAt, so it costs no extra round trip.
|
|
439
|
+
*/
|
|
440
|
+
recorderCapabilities: Array<string> | null;
|
|
441
|
+
}
|
|
442
|
+
|
|
280
443
|
/*
|
|
281
444
|
* Header columns that are aggregated with argMax and their SELECT alias.
|
|
282
445
|
* Kept as data rather than a hand-written SELECT list so the list, header
|
|
@@ -305,6 +468,44 @@ function argMaxDateTime(column: string): string {
|
|
|
305
468
|
return `toFloat64(toUnixTimestamp64Milli(${argMaxColumn(column)}))`;
|
|
306
469
|
}
|
|
307
470
|
|
|
471
|
+
/* A Date column (retentionDate) as unix milliseconds. */
|
|
472
|
+
function argMaxDate(column: string): string {
|
|
473
|
+
return `toFloat64(toUnixTimestamp(${argMaxColumn(column)})) * 1000`;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/*
|
|
477
|
+
* Duration that stays honest for a session the finalizer has not reached.
|
|
478
|
+
*
|
|
479
|
+
* The provisional header is written on chunk 0 with durationMs 0 and
|
|
480
|
+
* endTime = chunk 0's end, and it stays that way for the 10+ minutes of
|
|
481
|
+
* idleness the finalizer waits for. Reported verbatim, every live or
|
|
482
|
+
* recently finished session read "0s" in the list and a "longer than"
|
|
483
|
+
* filter hid all of them - the first thing a person testing their install
|
|
484
|
+
* sees is a session that claims to be empty. Until the finalized row
|
|
485
|
+
* exists, the span the header itself asserts (endTime - startTime) is the
|
|
486
|
+
* best lower bound there is, so the live value is the larger of the two.
|
|
487
|
+
* The finalized row's durationMs is authoritative and is used as-is.
|
|
488
|
+
*/
|
|
489
|
+
/*
|
|
490
|
+
* durationMs is Int128 on disk while the clock arithmetic is Int64; both
|
|
491
|
+
* branches are cast to Int64 (a session is capped at four hours, so the
|
|
492
|
+
* cast cannot overflow) so `if` and `greatest` see one type.
|
|
493
|
+
*/
|
|
494
|
+
const LIVE_DURATION_EXPRESSION: string = `toFloat64(if(${argMaxColumn(
|
|
495
|
+
"isFinalized",
|
|
496
|
+
)}, toInt64(${argMaxColumn("durationMs")}), greatest(toInt64(${argMaxColumn(
|
|
497
|
+
"durationMs",
|
|
498
|
+
)}), toUnixTimestamp64Milli(${argMaxColumn(
|
|
499
|
+
"endTime",
|
|
500
|
+
)}) - toUnixTimestamp64Milli(${argMaxColumn("startTime")}))))`;
|
|
501
|
+
|
|
502
|
+
/*
|
|
503
|
+
* The frustration total, shared by the hasFrustration predicate and the
|
|
504
|
+
* "frustration" sort so the two can never disagree about what counts.
|
|
505
|
+
*/
|
|
506
|
+
const FRUSTRATION_TOTAL_EXPRESSION: string =
|
|
507
|
+
"(aggRageClickCount + aggDeadClickCount + aggErrorClickCount + aggRefreshRageCount)";
|
|
508
|
+
|
|
308
509
|
/*
|
|
309
510
|
* Aliases deliberately differ from the physical column names. See the
|
|
310
511
|
* ILLEGAL_AGGREGATION note in the file header.
|
|
@@ -312,7 +513,7 @@ function argMaxDateTime(column: string): string {
|
|
|
312
513
|
const HEADER_AGGREGATES: Array<AggregatedColumn> = [
|
|
313
514
|
{ alias: "aggStartTime", expression: argMaxDateTime("startTime") },
|
|
314
515
|
{ alias: "aggEndTime", expression: argMaxDateTime("endTime") },
|
|
315
|
-
{ alias: "aggDurationMs", expression:
|
|
516
|
+
{ alias: "aggDurationMs", expression: LIVE_DURATION_EXPRESSION },
|
|
316
517
|
{ alias: "aggIsFinalized", expression: argMaxColumn("isFinalized") },
|
|
317
518
|
{ alias: "aggSealedReason", expression: argMaxColumn("sealedReason") },
|
|
318
519
|
{ alias: "aggChunkCount", expression: argMaxNumeric("chunkCount") },
|
|
@@ -336,6 +537,12 @@ const HEADER_AGGREGATES: Array<AggregatedColumn> = [
|
|
|
336
537
|
{ alias: "aggTriggerReason", expression: argMaxColumn("triggerReason") },
|
|
337
538
|
{ alias: "aggEntryUrl", expression: argMaxColumn("entryUrl") },
|
|
338
539
|
{ alias: "aggExitUrl", expression: argMaxColumn("exitUrl") },
|
|
540
|
+
/*
|
|
541
|
+
* The full routes array: the list projects the first MAX_LIST_ROUTES and
|
|
542
|
+
* the urlPrefix / search predicates run over the argMax'd whole, never
|
|
543
|
+
* the raw column (which would match a superseded header version).
|
|
544
|
+
*/
|
|
545
|
+
{ alias: "aggRoutes", expression: argMaxColumn("routes") },
|
|
339
546
|
{ alias: "aggBrowserName", expression: argMaxColumn("browserName") },
|
|
340
547
|
{ alias: "aggBrowserVersion", expression: argMaxColumn("browserVersion") },
|
|
341
548
|
{ alias: "aggOsName", expression: argMaxColumn("osName") },
|
|
@@ -351,6 +558,36 @@ const HEADER_AGGREGATES: Array<AggregatedColumn> = [
|
|
|
351
558
|
alias: "aggSamplePercentage",
|
|
352
559
|
expression: argMaxNumeric("samplePercentageAtCapture"),
|
|
353
560
|
},
|
|
561
|
+
/*
|
|
562
|
+
* Counts rather than the arrays themselves: the list only says "3 traces"
|
|
563
|
+
* and "2 exception groups", and the hasTraces predicate needs a number.
|
|
564
|
+
*/
|
|
565
|
+
{
|
|
566
|
+
alias: "aggTraceCount",
|
|
567
|
+
expression: `toFloat64(length(${argMaxColumn("traceIds")}))`,
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
alias: "aggExceptionGroupCount",
|
|
571
|
+
expression: `toFloat64(length(${argMaxColumn("exceptionFingerprints")}))`,
|
|
572
|
+
},
|
|
573
|
+
/*
|
|
574
|
+
* The first fingerprint, so the list's "3 errors" badge can link at the
|
|
575
|
+
* exception group instead of at an unfiltered Exceptions page. Empty
|
|
576
|
+
* string when the session recorded no exception group; arrayElement on
|
|
577
|
+
* an empty array returns the type's default, which for String is ''.
|
|
578
|
+
*/
|
|
579
|
+
{
|
|
580
|
+
alias: "aggTopExceptionFingerprint",
|
|
581
|
+
expression: `arrayElement(${argMaxColumn("exceptionFingerprints")}, 1)`,
|
|
582
|
+
},
|
|
583
|
+
{ alias: "aggClickCount", expression: argMaxNumeric("clickCount") },
|
|
584
|
+
{ alias: "aggActiveMs", expression: argMaxNumeric("activeMs") },
|
|
585
|
+
{
|
|
586
|
+
alias: "aggFirstErrorOffsetMs",
|
|
587
|
+
expression: argMaxNumeric("firstErrorOffsetMs"),
|
|
588
|
+
},
|
|
589
|
+
{ alias: "aggExpiresAt", expression: argMaxDate("retentionDate") },
|
|
590
|
+
{ alias: "aggTags", expression: argMaxColumn("tags") },
|
|
354
591
|
];
|
|
355
592
|
|
|
356
593
|
/* Only the manifest needs these; the list never renders them. */
|
|
@@ -362,7 +599,6 @@ const HEADER_DETAIL_AGGREGATES: Array<AggregatedColumn> = [
|
|
|
362
599
|
{ alias: "aggRrwebVersion", expression: argMaxColumn("rrwebVersion") },
|
|
363
600
|
{ alias: "aggSchemaVersion", expression: argMaxNumeric("schemaVersion") },
|
|
364
601
|
{ alias: "aggWireVersion", expression: argMaxNumeric("wireVersion") },
|
|
365
|
-
{ alias: "aggRoutes", expression: argMaxColumn("routes") },
|
|
366
602
|
{ alias: "aggFidelityNotices", expression: argMaxColumn("fidelityNotices") },
|
|
367
603
|
{
|
|
368
604
|
alias: "aggFullSnapshotChunkIndexes",
|
|
@@ -374,6 +610,31 @@ const HEADER_DETAIL_AGGREGATES: Array<AggregatedColumn> = [
|
|
|
374
610
|
expression: argMaxColumn("exceptionFingerprints"),
|
|
375
611
|
},
|
|
376
612
|
{ alias: "aggClockSkewMs", expression: argMaxNumeric("clockSkewMs") },
|
|
613
|
+
{
|
|
614
|
+
alias: "aggClientReportedStart",
|
|
615
|
+
expression: argMaxDateTime("clientReportedStartTime"),
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
alias: "aggCustomEventCount",
|
|
619
|
+
expression: argMaxNumeric("customEventCount"),
|
|
620
|
+
},
|
|
621
|
+
{ alias: "aggAttributes", expression: argMaxColumn("attributes") },
|
|
622
|
+
];
|
|
623
|
+
|
|
624
|
+
/*
|
|
625
|
+
* The two columns under the identity ACL. Named in a statement ONLY when
|
|
626
|
+
* the caller has already passed canReadIdentifiedUserLabel for the
|
|
627
|
+
* application the statement is pinned to.
|
|
628
|
+
*/
|
|
629
|
+
const IDENTITY_AGGREGATES: Array<AggregatedColumn> = [
|
|
630
|
+
{
|
|
631
|
+
alias: "aggIdentifiedUserLabel",
|
|
632
|
+
expression: argMaxColumn("identifiedUserLabel"),
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
alias: "aggIdentifiedUserTraits",
|
|
636
|
+
expression: argMaxColumn("identifiedUserTraits"),
|
|
637
|
+
},
|
|
377
638
|
];
|
|
378
639
|
|
|
379
640
|
function toSelectList(columns: Array<AggregatedColumn>): string {
|
|
@@ -456,6 +717,32 @@ function readNumberArray(row: JSONObject, key: string): Array<number> {
|
|
|
456
717
|
return numbers;
|
|
457
718
|
}
|
|
458
719
|
|
|
720
|
+
/*
|
|
721
|
+
* A Map(String, String) column. ClickHouse serialises it as a JSON object;
|
|
722
|
+
* anything else (including an array, or a row that predates the column)
|
|
723
|
+
* reads as an empty map.
|
|
724
|
+
*/
|
|
725
|
+
function readStringMap(row: JSONObject, key: string): Record<string, string> {
|
|
726
|
+
const value: unknown = row[key];
|
|
727
|
+
const result: Record<string, string> = {};
|
|
728
|
+
|
|
729
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
730
|
+
return result;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
for (const entryKey of Object.keys(value as Record<string, unknown>)) {
|
|
734
|
+
const entry: unknown = (value as Record<string, unknown>)[entryKey];
|
|
735
|
+
|
|
736
|
+
if (typeof entry === "string") {
|
|
737
|
+
result[entryKey] = entry;
|
|
738
|
+
} else if (typeof entry === "number" || typeof entry === "boolean") {
|
|
739
|
+
result[entryKey] = String(entry);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return result;
|
|
744
|
+
}
|
|
745
|
+
|
|
459
746
|
/*
|
|
460
747
|
* Unix millis -> Date. The queries return epoch milliseconds as a Float64
|
|
461
748
|
* precisely so no ClickHouse datetime string ever has to be re-parsed
|
|
@@ -466,7 +753,79 @@ function readDate(row: JSONObject, key: string): Date {
|
|
|
466
753
|
return new Date(readNumber(row, key));
|
|
467
754
|
}
|
|
468
755
|
|
|
756
|
+
/*
|
|
757
|
+
* The capability list chunk 0 declared, filtered to the vocabulary this
|
|
758
|
+
* build knows so a stored typo never reaches the player as a capability.
|
|
759
|
+
*/
|
|
760
|
+
function readRecorderCapabilities(row: JSONObject): Array<string> {
|
|
761
|
+
const attributes: Record<string, string> = readStringMap(
|
|
762
|
+
row,
|
|
763
|
+
"aggAttributes",
|
|
764
|
+
);
|
|
765
|
+
const raw: string | undefined = attributes[RECORDER_CAPABILITIES_ATTRIBUTE];
|
|
766
|
+
|
|
767
|
+
if (!raw) {
|
|
768
|
+
return [];
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return raw
|
|
772
|
+
.split(",")
|
|
773
|
+
.map((capability: string): string => {
|
|
774
|
+
return capability.trim();
|
|
775
|
+
})
|
|
776
|
+
.filter((capability: string): boolean => {
|
|
777
|
+
return SESSION_REPLAY_RECORDER_CAPABILITIES.includes(capability);
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
interface ActivitySummaryCacheEntry {
|
|
782
|
+
summary: SessionReplayApplicationActivitySummary;
|
|
783
|
+
expiresAt: number;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const activitySummaryCache: Map<string, ActivitySummaryCacheEntry> = new Map<
|
|
787
|
+
string,
|
|
788
|
+
ActivitySummaryCacheEntry
|
|
789
|
+
>();
|
|
790
|
+
|
|
791
|
+
/*
|
|
792
|
+
* Where the published recorder version comes from. The recorder manifest
|
|
793
|
+
* is read by App/FeatureSet/BrowserRecorder/Manifest.ts, which lives in
|
|
794
|
+
* the App tree and cannot be imported from Common; the feature set that
|
|
795
|
+
* mounts the read routes registers the reader at boot. Until it does, the
|
|
796
|
+
* ingest-status route answers null - "unknown", never a guessed version.
|
|
797
|
+
*/
|
|
798
|
+
type PublishedRecorderVersionProvider = () => string | null;
|
|
799
|
+
|
|
800
|
+
let publishedRecorderVersionProvider: PublishedRecorderVersionProvider | null =
|
|
801
|
+
null;
|
|
802
|
+
|
|
469
803
|
export default class SessionReplayReadService {
|
|
804
|
+
public static setPublishedRecorderVersionProvider(
|
|
805
|
+
provider: PublishedRecorderVersionProvider | null,
|
|
806
|
+
): void {
|
|
807
|
+
publishedRecorderVersionProvider = provider;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
public static getPublishedRecorderVersion(): string | null {
|
|
811
|
+
if (!publishedRecorderVersionProvider) {
|
|
812
|
+
return null;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
try {
|
|
816
|
+
const version: string | null = publishedRecorderVersionProvider();
|
|
817
|
+
|
|
818
|
+
return typeof version === "string" && version.length > 0 ? version : null;
|
|
819
|
+
} catch {
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/* Test seam: the summary cache is process-local. */
|
|
825
|
+
public static clearActivitySummaryCache(): void {
|
|
826
|
+
activitySummaryCache.clear();
|
|
827
|
+
}
|
|
828
|
+
|
|
470
829
|
/*
|
|
471
830
|
* Session list.
|
|
472
831
|
*
|
|
@@ -474,7 +833,10 @@ export default class SessionReplayReadService {
|
|
|
474
833
|
* are the first three elements of the sort key AND they are part of the
|
|
475
834
|
* ReplacingMergeTree replace key, so they are byte-identical on every
|
|
476
835
|
* duplicate row of a session. Filtering on them before the GROUP BY is
|
|
477
|
-
* therefore both index-friendly and safe.
|
|
836
|
+
* therefore both index-friendly and safe. Nothing else is ever added to
|
|
837
|
+
* the WHERE: any other predicate would have to run over raw rows and
|
|
838
|
+
* would either match a superseded header version or force a scan that
|
|
839
|
+
* the (projectId, rumApplicationId, startTime) prefix cannot prune.
|
|
478
840
|
*
|
|
479
841
|
* Everything else is filtered in HAVING against the argMax'd value.
|
|
480
842
|
* That is not a style choice: a provisional header (written on chunk 0,
|
|
@@ -492,6 +854,24 @@ export default class SessionReplayReadService {
|
|
|
492
854
|
Math.min(request.limit, MAX_SESSION_REPLAY_LIST_LIMIT),
|
|
493
855
|
);
|
|
494
856
|
|
|
857
|
+
const sortBy: SessionReplaySortBy = request.sortBy || "startTime";
|
|
858
|
+
|
|
859
|
+
if (!SESSION_REPLAY_SORT_BY_VALUES.includes(sortBy)) {
|
|
860
|
+
throw new BadDataException(
|
|
861
|
+
`sortBy must be one of ${SESSION_REPLAY_SORT_BY_VALUES.join(", ")}.`,
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
if (request.cursor && request.cursor.sortBy !== sortBy) {
|
|
866
|
+
/*
|
|
867
|
+
* A cursor is a position in ONE ordering. Applying a "most errors"
|
|
868
|
+
* cursor to a "newest" list would silently skip or repeat sessions.
|
|
869
|
+
*/
|
|
870
|
+
throw new BadDataException(
|
|
871
|
+
`The cursor belongs to a list sorted by ${request.cursor.sortBy}, not ${sortBy}. Start from the first page.`,
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
|
|
495
875
|
const selectList: string = toSelectList(HEADER_AGGREGATES);
|
|
496
876
|
|
|
497
877
|
const statement: Statement = SQL`
|
|
@@ -503,9 +883,7 @@ export default class SessionReplayReadService {
|
|
|
503
883
|
statement.append(` ${selectList}`);
|
|
504
884
|
|
|
505
885
|
if (request.includeIdentifiedUserLabel) {
|
|
506
|
-
statement.append(
|
|
507
|
-
`,\n ${argMaxColumn("identifiedUserLabel")} AS aggIdentifiedUserLabel`,
|
|
508
|
-
);
|
|
886
|
+
statement.append(`,\n ${toSelectList(IDENTITY_AGGREGATES)}`);
|
|
509
887
|
}
|
|
510
888
|
|
|
511
889
|
statement.append(SQL`
|
|
@@ -532,19 +910,24 @@ export default class SessionReplayReadService {
|
|
|
532
910
|
|
|
533
911
|
/*
|
|
534
912
|
* Keyset cursor. The sort key is (projectId, rumApplicationId,
|
|
535
|
-
* startTime, sessionId) and the list is ordered by the
|
|
536
|
-
* descending, so the previous page's last startTime is a
|
|
537
|
-
* WHERE-level upper bound: it prunes granules instead of paging
|
|
538
|
-
* OFFSET, which on a wide time window would re-read and
|
|
539
|
-
* everything already returned. The exact ties are
|
|
540
|
-
* HAVING tiebreak below - the WHERE bound is
|
|
541
|
-
* so a row sharing the boundary timestamp is
|
|
913
|
+
* startTime, sessionId) and the newest-first list is ordered by the
|
|
914
|
+
* same tuple descending, so the previous page's last startTime is a
|
|
915
|
+
* valid WHERE-level upper bound: it prunes granules instead of paging
|
|
916
|
+
* with OFFSET, which on a wide time window would re-read and
|
|
917
|
+
* re-aggregate everything already returned. The exact ties are
|
|
918
|
+
* removed by the HAVING tiebreak below - the WHERE bound is
|
|
919
|
+
* deliberately inclusive so a row sharing the boundary timestamp is
|
|
920
|
+
* not skipped.
|
|
921
|
+
*
|
|
922
|
+
* Only for the startTime sort: for any other key the cursor value is
|
|
923
|
+
* an aggregate, and a WHERE on startTime would drop sessions that
|
|
924
|
+
* belong on later pages.
|
|
542
925
|
*/
|
|
543
|
-
if (request.cursor) {
|
|
926
|
+
if (request.cursor && sortBy === "startTime") {
|
|
544
927
|
statement.append(
|
|
545
928
|
SQL` AND startTime <= ${{
|
|
546
929
|
type: TableColumnType.DateTime64,
|
|
547
|
-
value: new Date(request.cursor.
|
|
930
|
+
value: new Date(request.cursor.sortValue),
|
|
548
931
|
}}`,
|
|
549
932
|
);
|
|
550
933
|
}
|
|
@@ -556,16 +939,25 @@ export default class SessionReplayReadService {
|
|
|
556
939
|
SessionReplayReadService.appendListHavingFilters(
|
|
557
940
|
statement,
|
|
558
941
|
request.filters,
|
|
942
|
+
request.includeIdentifiedUserLabel,
|
|
559
943
|
);
|
|
560
944
|
|
|
945
|
+
const sortExpression: string =
|
|
946
|
+
SessionReplayReadService.getSortExpression(sortBy);
|
|
947
|
+
|
|
561
948
|
if (request.cursor) {
|
|
949
|
+
statement.append(` AND (${sortExpression} < `);
|
|
562
950
|
statement.append(
|
|
563
|
-
SQL
|
|
951
|
+
SQL`${{
|
|
564
952
|
type: TableColumnType.Decimal,
|
|
565
|
-
value: request.cursor.
|
|
566
|
-
}}
|
|
953
|
+
value: request.cursor.sortValue,
|
|
954
|
+
}}`,
|
|
955
|
+
);
|
|
956
|
+
statement.append(` OR (${sortExpression} = `);
|
|
957
|
+
statement.append(
|
|
958
|
+
SQL`${{
|
|
567
959
|
type: TableColumnType.Decimal,
|
|
568
|
-
value: request.cursor.
|
|
960
|
+
value: request.cursor.sortValue,
|
|
569
961
|
}} AND sessionId < ${{
|
|
570
962
|
type: TableColumnType.Text,
|
|
571
963
|
value: request.cursor.sessionId,
|
|
@@ -573,12 +965,12 @@ export default class SessionReplayReadService {
|
|
|
573
965
|
);
|
|
574
966
|
}
|
|
575
967
|
|
|
968
|
+
statement.append(` ORDER BY ${sortExpression} DESC, sessionId DESC`);
|
|
576
969
|
statement.append(
|
|
577
|
-
SQL`
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
}}`,
|
|
970
|
+
SQL` LIMIT ${{
|
|
971
|
+
type: TableColumnType.Number,
|
|
972
|
+
value: limit + 1,
|
|
973
|
+
}}`,
|
|
582
974
|
);
|
|
583
975
|
|
|
584
976
|
statement.append(READ_QUERY_SETTINGS);
|
|
@@ -600,11 +992,14 @@ export default class SessionReplayReadService {
|
|
|
600
992
|
|
|
601
993
|
const sessions: Array<SessionReplayListItem> = pageRows.map(
|
|
602
994
|
(row: JSONObject): SessionReplayListItem => {
|
|
995
|
+
const startTime: Date = readDate(row, "aggStartTime");
|
|
996
|
+
const endTime: Date = readDate(row, "aggEndTime");
|
|
997
|
+
|
|
603
998
|
const item: SessionReplayListItem = {
|
|
604
999
|
sessionId: readString(row, "sessionId"),
|
|
605
1000
|
rumApplicationId: readString(row, "applicationId"),
|
|
606
|
-
startTime:
|
|
607
|
-
endTime:
|
|
1001
|
+
startTime: startTime,
|
|
1002
|
+
endTime: endTime,
|
|
608
1003
|
durationMs: readNumber(row, "aggDurationMs"),
|
|
609
1004
|
isFinalized: readBoolean(row, "aggIsFinalized"),
|
|
610
1005
|
sealedReason: readString(row, "aggSealedReason"),
|
|
@@ -632,10 +1027,28 @@ export default class SessionReplayReadService {
|
|
|
632
1027
|
viewportHeight: readNumber(row, "aggViewportHeight"),
|
|
633
1028
|
identifiedUserKey: readString(row, "aggIdentifiedUserKey"),
|
|
634
1029
|
samplePercentageAtCapture: readNumber(row, "aggSamplePercentage"),
|
|
1030
|
+
routes: readStringArray(row, "aggRoutes").slice(0, MAX_LIST_ROUTES),
|
|
1031
|
+
traceCount: readNumber(row, "aggTraceCount"),
|
|
1032
|
+
exceptionGroupCount: readNumber(row, "aggExceptionGroupCount"),
|
|
1033
|
+
topExceptionFingerprint: readString(
|
|
1034
|
+
row,
|
|
1035
|
+
"aggTopExceptionFingerprint",
|
|
1036
|
+
),
|
|
1037
|
+
clickCount: readNumber(row, "aggClickCount"),
|
|
1038
|
+
activeMs: readNumber(row, "aggActiveMs"),
|
|
1039
|
+
firstErrorOffsetMs: readNumber(row, "aggFirstErrorOffsetMs"),
|
|
1040
|
+
expiresAtUnixMs: readNumber(row, "aggExpiresAt"),
|
|
1041
|
+
tags: readStringMap(row, "aggTags"),
|
|
1042
|
+
startTimeUnixMs: startTime.getTime(),
|
|
1043
|
+
endTimeUnixMs: endTime.getTime(),
|
|
635
1044
|
};
|
|
636
1045
|
|
|
637
1046
|
if (request.includeIdentifiedUserLabel) {
|
|
638
1047
|
item.identifiedUserLabel = readString(row, "aggIdentifiedUserLabel");
|
|
1048
|
+
item.identifiedUserTraits = readStringMap(
|
|
1049
|
+
row,
|
|
1050
|
+
"aggIdentifiedUserTraits",
|
|
1051
|
+
);
|
|
639
1052
|
}
|
|
640
1053
|
|
|
641
1054
|
return item;
|
|
@@ -650,7 +1063,11 @@ export default class SessionReplayReadService {
|
|
|
650
1063
|
nextCursor:
|
|
651
1064
|
hasMore && lastSession
|
|
652
1065
|
? {
|
|
653
|
-
|
|
1066
|
+
sortBy: sortBy,
|
|
1067
|
+
sortValue: SessionReplayReadService.getSortValue(
|
|
1068
|
+
sortBy,
|
|
1069
|
+
lastSession,
|
|
1070
|
+
),
|
|
654
1071
|
sessionId: lastSession.sessionId,
|
|
655
1072
|
}
|
|
656
1073
|
: null,
|
|
@@ -663,14 +1080,22 @@ export default class SessionReplayReadService {
|
|
|
663
1080
|
*
|
|
664
1081
|
* This is also what resolves a sessionId to its owning RUM application
|
|
665
1082
|
* for the handler-level authorization check, which is why it is keyed
|
|
666
|
-
* on (projectId, sessionId)
|
|
667
|
-
*
|
|
668
|
-
*
|
|
1083
|
+
* on (projectId, sessionId) and the optional rumApplicationId is a
|
|
1084
|
+
* DISAMBIGUATOR, never a substitute for the check: a supplied id only
|
|
1085
|
+
* narrows which header row is read, and the handler still authorizes
|
|
1086
|
+
* the application that row names.
|
|
669
1087
|
*/
|
|
670
1088
|
@CaptureSpan()
|
|
671
1089
|
public static async getSessionHeader(data: {
|
|
672
1090
|
projectId: ObjectID;
|
|
673
1091
|
sessionId: string;
|
|
1092
|
+
/*
|
|
1093
|
+
* Which application's recording to read when the same browser-minted
|
|
1094
|
+
* sessionId was recorded under more than one application (an
|
|
1095
|
+
* appIdentifier rename, two apps on one origin). Without it an
|
|
1096
|
+
* ambiguous id is refused.
|
|
1097
|
+
*/
|
|
1098
|
+
rumApplicationId?: ObjectID | undefined;
|
|
674
1099
|
}): Promise<SessionReplaySessionHeader | null> {
|
|
675
1100
|
const selectList: string = toSelectList([
|
|
676
1101
|
...HEADER_AGGREGATES,
|
|
@@ -698,6 +1123,15 @@ export default class SessionReplayReadService {
|
|
|
698
1123
|
}}
|
|
699
1124
|
`);
|
|
700
1125
|
|
|
1126
|
+
if (data.rumApplicationId) {
|
|
1127
|
+
statement.append(
|
|
1128
|
+
SQL` AND rumApplicationId = ${{
|
|
1129
|
+
type: TableColumnType.ObjectID,
|
|
1130
|
+
value: data.rumApplicationId,
|
|
1131
|
+
}}`,
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
701
1135
|
statement.append(RETENTION_FILTER);
|
|
702
1136
|
|
|
703
1137
|
/*
|
|
@@ -711,9 +1145,10 @@ export default class SessionReplayReadService {
|
|
|
711
1145
|
* sessionId share a key space. Picking the newest group would let
|
|
712
1146
|
* anyone who can write to application A resolve a sessionId belonging
|
|
713
1147
|
* to application B onto their own application and pass the label
|
|
714
|
-
* check. An ambiguous sessionId is refused outright
|
|
715
|
-
*
|
|
716
|
-
*
|
|
1148
|
+
* check. An ambiguous sessionId is refused outright unless the caller
|
|
1149
|
+
* named the application it wants (which is then authorized on its own
|
|
1150
|
+
* merits): it is either an attack or a collision, and neither has a
|
|
1151
|
+
* single correct recording to return.
|
|
717
1152
|
*/
|
|
718
1153
|
statement.append(
|
|
719
1154
|
" GROUP BY projectId, rumApplicationId, sessionId ORDER BY aggStartTime DESC LIMIT 2",
|
|
@@ -730,7 +1165,7 @@ export default class SessionReplayReadService {
|
|
|
730
1165
|
|
|
731
1166
|
if (rows.length > 1) {
|
|
732
1167
|
throw new BadDataException(
|
|
733
|
-
"This session id
|
|
1168
|
+
"This session id was recorded under more than one application in this project. Open it from the session list of the application you want to watch, which passes rumApplicationId to choose the recording.",
|
|
734
1169
|
);
|
|
735
1170
|
}
|
|
736
1171
|
|
|
@@ -740,12 +1175,15 @@ export default class SessionReplayReadService {
|
|
|
740
1175
|
return null;
|
|
741
1176
|
}
|
|
742
1177
|
|
|
1178
|
+
const startTime: Date = readDate(row, "aggStartTime");
|
|
1179
|
+
const endTime: Date = readDate(row, "aggEndTime");
|
|
1180
|
+
|
|
743
1181
|
return {
|
|
744
1182
|
sessionId: readString(row, "sessionId"),
|
|
745
1183
|
projectId: readString(row, "headerProjectId"),
|
|
746
1184
|
rumApplicationId: readString(row, "applicationId"),
|
|
747
|
-
startTime:
|
|
748
|
-
endTime:
|
|
1185
|
+
startTime: startTime,
|
|
1186
|
+
endTime: endTime,
|
|
749
1187
|
durationMs: readNumber(row, "aggDurationMs"),
|
|
750
1188
|
isFinalized: readBoolean(row, "aggIsFinalized"),
|
|
751
1189
|
sealedReason: readString(row, "aggSealedReason"),
|
|
@@ -787,57 +1225,43 @@ export default class SessionReplayReadService {
|
|
|
787
1225
|
traceIds: readStringArray(row, "aggTraceIds"),
|
|
788
1226
|
exceptionFingerprints: readStringArray(row, "aggExceptionFingerprints"),
|
|
789
1227
|
clockSkewMs: readNumber(row, "aggClockSkewMs"),
|
|
1228
|
+
startTimeUnixMs: startTime.getTime(),
|
|
1229
|
+
endTimeUnixMs: endTime.getTime(),
|
|
1230
|
+
clientReportedStartUnixMs: readNumber(row, "aggClientReportedStart"),
|
|
1231
|
+
tags: readStringMap(row, "aggTags"),
|
|
1232
|
+
expiresAtUnixMs: readNumber(row, "aggExpiresAt"),
|
|
1233
|
+
clickCount: readNumber(row, "aggClickCount"),
|
|
1234
|
+
customEventCount: readNumber(row, "aggCustomEventCount"),
|
|
1235
|
+
activeMs: readNumber(row, "aggActiveMs"),
|
|
1236
|
+
firstErrorOffsetMs: readNumber(row, "aggFirstErrorOffsetMs"),
|
|
1237
|
+
recorderCapabilities: readRecorderCapabilities(row),
|
|
790
1238
|
};
|
|
791
1239
|
}
|
|
792
1240
|
|
|
793
1241
|
/*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
799
|
-
*
|
|
800
|
-
*
|
|
1242
|
+
* The identity columns for one session, pinned to the application the
|
|
1243
|
+
* caller was authorized against. This is the ONLY statement outside the
|
|
1244
|
+
* identity-gated list projection that names identifiedUserLabel or
|
|
1245
|
+
* identifiedUserTraits, and the manifest handler calls it strictly
|
|
1246
|
+
* after canReadIdentifiedUserLabel has passed for this application. It
|
|
1247
|
+
* is a separate, tiny read rather than two more columns on
|
|
1248
|
+
* getSessionHeader because the header is resolved BEFORE the
|
|
1249
|
+
* application (and therefore the identity decision) is known.
|
|
801
1250
|
*/
|
|
802
1251
|
@CaptureSpan()
|
|
803
|
-
public static async
|
|
804
|
-
header: SessionReplaySessionHeader;
|
|
1252
|
+
public static async getSessionIdentity(data: {
|
|
805
1253
|
projectId: ObjectID;
|
|
806
|
-
/*
|
|
807
|
-
* The application the caller was actually authorized against, always
|
|
808
|
-
* the one resolved from the session header server-side. Every chunk
|
|
809
|
-
* read is pinned to it: the chunk table's replace key does not
|
|
810
|
-
* include rumApplicationId, so (projectId, sessionId) alone is not a
|
|
811
|
-
* tenant-safe key once a sessionId can be reused across
|
|
812
|
-
* applications.
|
|
813
|
-
*/
|
|
814
1254
|
rumApplicationId: ObjectID;
|
|
815
1255
|
sessionId: string;
|
|
816
|
-
}): Promise<
|
|
817
|
-
/*
|
|
818
|
-
* LIMIT 1 BY (tabId, chunkIndex) after ORDER BY ... version DESC
|
|
819
|
-
* keeps exactly the highest-version row per chunk. tabId is part of
|
|
820
|
-
* the group because chunkIndex is minted per tab. Ordering by
|
|
821
|
-
* tabId/chunkIndex first (rather than by version alone) leaves the
|
|
822
|
-
* output already sorted for the caller - LIMIT BY runs after ORDER
|
|
823
|
-
* BY, so the version DESC tiebreak still selects the right row.
|
|
824
|
-
*/
|
|
1256
|
+
}): Promise<SessionReplaySessionIdentity> {
|
|
825
1257
|
const statement: Statement = SQL`
|
|
826
1258
|
SELECT
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
toFloat64(payloadBytes) AS chunkPayloadBytes,
|
|
834
|
-
errorCount,
|
|
835
|
-
rageClickCount,
|
|
836
|
-
deadClickCount,
|
|
837
|
-
errorClickCount,
|
|
838
|
-
refreshRageCount,
|
|
839
|
-
routeCount
|
|
840
|
-
FROM ${AnalyticsTableName.RumSessionChunk}
|
|
1259
|
+
`;
|
|
1260
|
+
|
|
1261
|
+
statement.append(` ${toSelectList(IDENTITY_AGGREGATES)}`);
|
|
1262
|
+
|
|
1263
|
+
statement.append(SQL`
|
|
1264
|
+
FROM ${AnalyticsTableName.RumSession}
|
|
841
1265
|
WHERE projectId = ${{
|
|
842
1266
|
type: TableColumnType.ObjectID,
|
|
843
1267
|
value: data.projectId,
|
|
@@ -850,55 +1274,221 @@ export default class SessionReplayReadService {
|
|
|
850
1274
|
type: TableColumnType.Text,
|
|
851
1275
|
value: data.sessionId,
|
|
852
1276
|
}}
|
|
853
|
-
|
|
1277
|
+
`);
|
|
854
1278
|
|
|
855
1279
|
statement.append(RETENTION_FILTER);
|
|
856
|
-
|
|
857
1280
|
statement.append(
|
|
858
|
-
|
|
859
|
-
LIMIT 1 BY tabId, chunkIndex
|
|
860
|
-
LIMIT ${{
|
|
861
|
-
type: TableColumnType.Number,
|
|
862
|
-
value: MAX_MANIFEST_ROWS,
|
|
863
|
-
}}`,
|
|
1281
|
+
" GROUP BY projectId, rumApplicationId, sessionId LIMIT 1",
|
|
864
1282
|
);
|
|
865
|
-
|
|
866
1283
|
statement.append(READ_QUERY_SETTINGS);
|
|
867
1284
|
|
|
868
|
-
const dbResult: Results =
|
|
869
|
-
await RumSessionChunkService.executeQuery(statement);
|
|
1285
|
+
const dbResult: Results = await RumSessionService.executeQuery(statement);
|
|
870
1286
|
const response: DbJSONResponse = await dbResult.json<{
|
|
871
1287
|
data?: Array<JSONObject>;
|
|
872
1288
|
}>();
|
|
873
1289
|
|
|
874
|
-
const
|
|
875
|
-
|
|
876
|
-
const tabsById: Map<
|
|
877
|
-
string,
|
|
878
|
-
Array<SessionReplayChunkManifestEntry>
|
|
879
|
-
> = new Map<string, Array<SessionReplayChunkManifestEntry>>();
|
|
880
|
-
|
|
881
|
-
for (const row of rows) {
|
|
882
|
-
const tabId: string = readString(row, "tabId");
|
|
1290
|
+
const row: JSONObject | undefined = (response.data || [])[0];
|
|
883
1291
|
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
chunkStartOffsetMs: readNumber(row, "chunkStartOffsetMs"),
|
|
888
|
-
chunkEndOffsetMs: readNumber(row, "chunkEndOffsetMs"),
|
|
889
|
-
eventCount: readNumber(row, "eventCount"),
|
|
890
|
-
hasFullSnapshot: readBoolean(row, "hasFullSnapshot"),
|
|
891
|
-
payloadBytes: readNumber(row, "chunkPayloadBytes"),
|
|
892
|
-
errorCount: readNumber(row, "errorCount"),
|
|
893
|
-
rageClickCount: readNumber(row, "rageClickCount"),
|
|
894
|
-
deadClickCount: readNumber(row, "deadClickCount"),
|
|
895
|
-
errorClickCount: readNumber(row, "errorClickCount"),
|
|
896
|
-
refreshRageCount: readNumber(row, "refreshRageCount"),
|
|
897
|
-
routeCount: readNumber(row, "routeCount"),
|
|
898
|
-
};
|
|
1292
|
+
if (!row) {
|
|
1293
|
+
return { identifiedUserLabel: "", identifiedUserTraits: {} };
|
|
1294
|
+
}
|
|
899
1295
|
|
|
900
|
-
|
|
901
|
-
|
|
1296
|
+
return {
|
|
1297
|
+
identifiedUserLabel: readString(row, "aggIdentifiedUserLabel"),
|
|
1298
|
+
identifiedUserTraits: readStringMap(row, "aggIdentifiedUserTraits"),
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/*
|
|
1303
|
+
* For a sessionId that getSessionHeader could not find: did a header
|
|
1304
|
+
* ever exist, and when did (or does) it expire? Runs WITHOUT the
|
|
1305
|
+
* retention filter, which is safe only because it returns dates and an
|
|
1306
|
+
* application id and never a row's content - it lets the handler say
|
|
1307
|
+
* "this recording expired on <date>" instead of "not found".
|
|
1308
|
+
*
|
|
1309
|
+
* null when no row exists at all (never recorded, or already dropped by
|
|
1310
|
+
* the ClickHouse TTL, or erased).
|
|
1311
|
+
*/
|
|
1312
|
+
@CaptureSpan()
|
|
1313
|
+
public static async getExpiredSessionInfo(data: {
|
|
1314
|
+
projectId: ObjectID;
|
|
1315
|
+
sessionId: string;
|
|
1316
|
+
rumApplicationId?: ObjectID | undefined;
|
|
1317
|
+
}): Promise<SessionReplayExpiredSessionInfo | null> {
|
|
1318
|
+
const statement: Statement = SQL`
|
|
1319
|
+
SELECT
|
|
1320
|
+
toString(rumApplicationId) AS applicationId,
|
|
1321
|
+
toFloat64(toUnixTimestamp(max(retentionDate))) * 1000 AS expiresAtUnixMs,
|
|
1322
|
+
toFloat64(toUnixTimestamp64Milli(min(startTime))) AS startTimeUnixMs
|
|
1323
|
+
FROM ${AnalyticsTableName.RumSession}
|
|
1324
|
+
WHERE projectId = ${{
|
|
1325
|
+
type: TableColumnType.ObjectID,
|
|
1326
|
+
value: data.projectId,
|
|
1327
|
+
}}
|
|
1328
|
+
AND sessionId = ${{
|
|
1329
|
+
type: TableColumnType.Text,
|
|
1330
|
+
value: data.sessionId,
|
|
1331
|
+
}}
|
|
1332
|
+
`;
|
|
1333
|
+
|
|
1334
|
+
if (data.rumApplicationId) {
|
|
1335
|
+
statement.append(
|
|
1336
|
+
SQL` AND rumApplicationId = ${{
|
|
1337
|
+
type: TableColumnType.ObjectID,
|
|
1338
|
+
value: data.rumApplicationId,
|
|
1339
|
+
}}`,
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
statement.append(
|
|
1344
|
+
" GROUP BY rumApplicationId ORDER BY expiresAtUnixMs DESC LIMIT 1",
|
|
1345
|
+
);
|
|
1346
|
+
statement.append(READ_QUERY_SETTINGS);
|
|
1347
|
+
|
|
1348
|
+
const dbResult: Results = await RumSessionService.executeQuery(statement);
|
|
1349
|
+
const response: DbJSONResponse = await dbResult.json<{
|
|
1350
|
+
data?: Array<JSONObject>;
|
|
1351
|
+
}>();
|
|
1352
|
+
|
|
1353
|
+
const row: JSONObject | undefined = (response.data || [])[0];
|
|
1354
|
+
|
|
1355
|
+
if (!row) {
|
|
1356
|
+
return null;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
return {
|
|
1360
|
+
rumApplicationId: readString(row, "applicationId"),
|
|
1361
|
+
startTime: readDate(row, "startTimeUnixMs"),
|
|
1362
|
+
expiresAt: readDate(row, "expiresAtUnixMs"),
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/*
|
|
1367
|
+
* Playback manifest: everything the player needs to draw a complete,
|
|
1368
|
+
* honest timeline without fetching one payload byte.
|
|
1369
|
+
*
|
|
1370
|
+
* The `payload` column is deliberately absent from this SELECT. That is
|
|
1371
|
+
* the entire performance story of the feature: a 14-chunk session is
|
|
1372
|
+
* one 128-row granule of a handful of narrow columns (~2 KB) instead of
|
|
1373
|
+
* megabytes of decompressed recording.
|
|
1374
|
+
*/
|
|
1375
|
+
@CaptureSpan()
|
|
1376
|
+
public static async getManifest(data: {
|
|
1377
|
+
header: SessionReplaySessionHeader;
|
|
1378
|
+
projectId: ObjectID;
|
|
1379
|
+
/*
|
|
1380
|
+
* The application the caller was actually authorized against, always
|
|
1381
|
+
* the one resolved from the session header server-side. Every chunk
|
|
1382
|
+
* read is pinned to it: the chunk table's replace key does not
|
|
1383
|
+
* include rumApplicationId, so (projectId, sessionId) alone is not a
|
|
1384
|
+
* tenant-safe key once a sessionId can be reused across
|
|
1385
|
+
* applications.
|
|
1386
|
+
*/
|
|
1387
|
+
rumApplicationId: ObjectID;
|
|
1388
|
+
sessionId: string;
|
|
1389
|
+
}): Promise<SessionReplayManifest> {
|
|
1390
|
+
/*
|
|
1391
|
+
* LIMIT 1 BY (tabId, chunkIndex) after ORDER BY ... version DESC
|
|
1392
|
+
* keeps exactly the highest-version row per chunk. tabId is part of
|
|
1393
|
+
* the group because chunkIndex is minted per tab. Ordering by
|
|
1394
|
+
* tabId/chunkIndex first (rather than by version alone) leaves the
|
|
1395
|
+
* output already sorted for the caller - LIMIT BY runs after ORDER
|
|
1396
|
+
* BY, so the version DESC tiebreak still selects the right row.
|
|
1397
|
+
*
|
|
1398
|
+
* clickCount and url are narrow columns: the activity lane and the
|
|
1399
|
+
* URL bar read them before any chunk is decoded. payloadBytes stays
|
|
1400
|
+
* the WIRE size the recorder posted; the stored size is only ever
|
|
1401
|
+
* measured by getChunks, which is the read the cap actually bounds.
|
|
1402
|
+
*/
|
|
1403
|
+
const statement: Statement = SQL`
|
|
1404
|
+
SELECT
|
|
1405
|
+
tabId,
|
|
1406
|
+
chunkIndex,
|
|
1407
|
+
chunkStartOffsetMs,
|
|
1408
|
+
chunkEndOffsetMs,
|
|
1409
|
+
eventCount,
|
|
1410
|
+
hasFullSnapshot,
|
|
1411
|
+
toFloat64(payloadBytes) AS chunkPayloadBytes,
|
|
1412
|
+
errorCount,
|
|
1413
|
+
rageClickCount,
|
|
1414
|
+
deadClickCount,
|
|
1415
|
+
errorClickCount,
|
|
1416
|
+
refreshRageCount,
|
|
1417
|
+
routeCount,
|
|
1418
|
+
clickCount,
|
|
1419
|
+
url
|
|
1420
|
+
FROM ${AnalyticsTableName.RumSessionChunk}
|
|
1421
|
+
WHERE projectId = ${{
|
|
1422
|
+
type: TableColumnType.ObjectID,
|
|
1423
|
+
value: data.projectId,
|
|
1424
|
+
}}
|
|
1425
|
+
AND rumApplicationId = ${{
|
|
1426
|
+
type: TableColumnType.ObjectID,
|
|
1427
|
+
value: data.rumApplicationId,
|
|
1428
|
+
}}
|
|
1429
|
+
AND sessionId = ${{
|
|
1430
|
+
type: TableColumnType.Text,
|
|
1431
|
+
value: data.sessionId,
|
|
1432
|
+
}}
|
|
1433
|
+
`;
|
|
1434
|
+
|
|
1435
|
+
statement.append(RETENTION_FILTER);
|
|
1436
|
+
|
|
1437
|
+
statement.append(
|
|
1438
|
+
SQL` ORDER BY tabId ASC, chunkIndex ASC, version DESC
|
|
1439
|
+
LIMIT 1 BY tabId, chunkIndex
|
|
1440
|
+
LIMIT ${{
|
|
1441
|
+
type: TableColumnType.Number,
|
|
1442
|
+
value: MAX_MANIFEST_ROWS,
|
|
1443
|
+
}}`,
|
|
1444
|
+
);
|
|
1445
|
+
|
|
1446
|
+
statement.append(READ_QUERY_SETTINGS);
|
|
1447
|
+
|
|
1448
|
+
const dbResult: Results =
|
|
1449
|
+
await RumSessionChunkService.executeQuery(statement);
|
|
1450
|
+
const response: DbJSONResponse = await dbResult.json<{
|
|
1451
|
+
data?: Array<JSONObject>;
|
|
1452
|
+
}>();
|
|
1453
|
+
|
|
1454
|
+
const rows: Array<JSONObject> = response.data || [];
|
|
1455
|
+
|
|
1456
|
+
const tabsById: Map<
|
|
1457
|
+
string,
|
|
1458
|
+
Array<SessionReplayChunkManifestEntry>
|
|
1459
|
+
> = new Map<string, Array<SessionReplayChunkManifestEntry>>();
|
|
1460
|
+
|
|
1461
|
+
let liveDurationMs: number = 0;
|
|
1462
|
+
let liveEventCount: number = 0;
|
|
1463
|
+
let liveMaxChunkIndex: number = 0;
|
|
1464
|
+
|
|
1465
|
+
for (const row of rows) {
|
|
1466
|
+
const tabId: string = readString(row, "tabId");
|
|
1467
|
+
|
|
1468
|
+
const entry: SessionReplayChunkManifestEntry = {
|
|
1469
|
+
chunkIndex: readNumber(row, "chunkIndex"),
|
|
1470
|
+
tabId: tabId,
|
|
1471
|
+
chunkStartOffsetMs: readNumber(row, "chunkStartOffsetMs"),
|
|
1472
|
+
chunkEndOffsetMs: readNumber(row, "chunkEndOffsetMs"),
|
|
1473
|
+
eventCount: readNumber(row, "eventCount"),
|
|
1474
|
+
hasFullSnapshot: readBoolean(row, "hasFullSnapshot"),
|
|
1475
|
+
payloadBytes: readNumber(row, "chunkPayloadBytes"),
|
|
1476
|
+
errorCount: readNumber(row, "errorCount"),
|
|
1477
|
+
rageClickCount: readNumber(row, "rageClickCount"),
|
|
1478
|
+
deadClickCount: readNumber(row, "deadClickCount"),
|
|
1479
|
+
errorClickCount: readNumber(row, "errorClickCount"),
|
|
1480
|
+
refreshRageCount: readNumber(row, "refreshRageCount"),
|
|
1481
|
+
routeCount: readNumber(row, "routeCount"),
|
|
1482
|
+
clickCount: readNumber(row, "clickCount"),
|
|
1483
|
+
url: readString(row, "url"),
|
|
1484
|
+
};
|
|
1485
|
+
|
|
1486
|
+
liveDurationMs = Math.max(liveDurationMs, entry.chunkEndOffsetMs);
|
|
1487
|
+
liveEventCount += entry.eventCount;
|
|
1488
|
+
liveMaxChunkIndex = Math.max(liveMaxChunkIndex, entry.chunkIndex);
|
|
1489
|
+
|
|
1490
|
+
const existing: Array<SessionReplayChunkManifestEntry> | undefined =
|
|
1491
|
+
tabsById.get(tabId);
|
|
902
1492
|
|
|
903
1493
|
if (existing) {
|
|
904
1494
|
existing.push(entry);
|
|
@@ -944,107 +1534,95 @@ export default class SessionReplayReadService {
|
|
|
944
1534
|
},
|
|
945
1535
|
0,
|
|
946
1536
|
),
|
|
1537
|
+
firstChunkStartOffsetMs: entries.reduce(
|
|
1538
|
+
(min: number, entry: SessionReplayChunkManifestEntry): number => {
|
|
1539
|
+
return Math.min(min, entry.chunkStartOffsetMs);
|
|
1540
|
+
},
|
|
1541
|
+
Number.POSITIVE_INFINITY,
|
|
1542
|
+
),
|
|
947
1543
|
});
|
|
948
1544
|
}
|
|
949
1545
|
|
|
1546
|
+
for (const tab of tabs) {
|
|
1547
|
+
if (!Number.isFinite(tab.firstChunkStartOffsetMs)) {
|
|
1548
|
+
tab.firstChunkStartOffsetMs = 0;
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
950
1552
|
return {
|
|
951
|
-
header:
|
|
1553
|
+
header: SessionReplayReadService.reconcileLiveHeader({
|
|
1554
|
+
header: data.header,
|
|
1555
|
+
chunkRowCount: rows.length,
|
|
1556
|
+
liveDurationMs: liveDurationMs,
|
|
1557
|
+
liveEventCount: liveEventCount,
|
|
1558
|
+
liveMaxChunkIndex: liveMaxChunkIndex,
|
|
1559
|
+
}),
|
|
952
1560
|
tabs: tabs,
|
|
953
1561
|
isChunkIndexTruncated: rows.length >= MAX_MANIFEST_ROWS,
|
|
954
1562
|
};
|
|
955
1563
|
}
|
|
956
1564
|
|
|
957
1565
|
/*
|
|
958
|
-
*
|
|
959
|
-
*
|
|
960
|
-
*
|
|
961
|
-
*
|
|
962
|
-
* different quantities: payloadBytes is the post-gzip WIRE size the
|
|
963
|
-
* recorder uploaded (the metering signal), while the payload column
|
|
964
|
-
* holds the DECOMPRESSED JSON and is what this endpoint actually
|
|
965
|
-
* returns. rrweb JSON gzips 10-20x, so a cap applied to payloadBytes
|
|
966
|
-
* bounds a number an order of magnitude smaller than the response and
|
|
967
|
-
* therefore bounds nothing useful.
|
|
968
|
-
*
|
|
969
|
-
* The cost is that ClickHouse has to decompress the column to measure
|
|
970
|
-
* it, which is exactly what naming `payload` was meant to avoid. It is
|
|
971
|
-
* still worth doing before the read rather than after: the bytes are
|
|
972
|
-
* measured inside ClickHouse and never cross the wire, and the marks
|
|
973
|
-
* the following read needs are warm by the time it runs.
|
|
1566
|
+
* A provisional header (isFinalized false) says durationMs 0, chunkCount
|
|
1567
|
+
* 0 and eventCount 0 while its chunk rows say otherwise; the manifest
|
|
1568
|
+
* has just read every chunk row, so it reports what the rows prove. A
|
|
1569
|
+
* finalized header is authoritative and returned untouched.
|
|
974
1570
|
*/
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
return 0;
|
|
1571
|
+
private static reconcileLiveHeader(data: {
|
|
1572
|
+
header: SessionReplaySessionHeader;
|
|
1573
|
+
chunkRowCount: number;
|
|
1574
|
+
liveDurationMs: number;
|
|
1575
|
+
liveEventCount: number;
|
|
1576
|
+
liveMaxChunkIndex: number;
|
|
1577
|
+
}): SessionReplaySessionHeader {
|
|
1578
|
+
if (data.header.isFinalized || data.chunkRowCount === 0) {
|
|
1579
|
+
return data.header;
|
|
985
1580
|
}
|
|
986
1581
|
|
|
987
|
-
const
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
toFloat64(length(payload)) AS chunkStoredBytes
|
|
991
|
-
FROM ${AnalyticsTableName.RumSessionChunk}
|
|
992
|
-
WHERE projectId = ${{
|
|
993
|
-
type: TableColumnType.ObjectID,
|
|
994
|
-
value: data.projectId,
|
|
995
|
-
}}
|
|
996
|
-
AND rumApplicationId = ${{
|
|
997
|
-
type: TableColumnType.ObjectID,
|
|
998
|
-
value: data.rumApplicationId,
|
|
999
|
-
}}
|
|
1000
|
-
AND sessionId = ${{
|
|
1001
|
-
type: TableColumnType.Text,
|
|
1002
|
-
value: data.sessionId,
|
|
1003
|
-
}}
|
|
1004
|
-
AND tabId = ${{
|
|
1005
|
-
type: TableColumnType.Text,
|
|
1006
|
-
value: data.tabId,
|
|
1007
|
-
}}
|
|
1008
|
-
AND chunkIndex IN (${{
|
|
1009
|
-
type: TableColumnType.Number,
|
|
1010
|
-
value: new Includes(data.chunkIndexes),
|
|
1011
|
-
}})
|
|
1012
|
-
`;
|
|
1013
|
-
|
|
1014
|
-
statement.append(RETENTION_FILTER);
|
|
1015
|
-
|
|
1016
|
-
/*
|
|
1017
|
-
* Deduplicated exactly like the payload read, so the pre-check and
|
|
1018
|
-
* the read it guards can never disagree about which rows count.
|
|
1019
|
-
* Summed in TypeScript rather than in SQL because the row count is
|
|
1020
|
-
* bounded by MAX_SESSION_REPLAY_CHUNKS_PER_READ, and a wrapping
|
|
1021
|
-
* aggregate would need a subquery whose LIMIT BY semantics are
|
|
1022
|
-
* easier to get subtly wrong than to read.
|
|
1023
|
-
*/
|
|
1024
|
-
statement.append(
|
|
1025
|
-
" ORDER BY chunkIndex ASC, version DESC LIMIT 1 BY chunkIndex",
|
|
1582
|
+
const durationMs: number = Math.max(
|
|
1583
|
+
data.header.durationMs,
|
|
1584
|
+
data.liveDurationMs,
|
|
1026
1585
|
);
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
const dbResult: Results =
|
|
1031
|
-
await RumSessionChunkService.executeQuery(statement);
|
|
1032
|
-
const response: DbJSONResponse = await dbResult.json<{
|
|
1033
|
-
data?: Array<JSONObject>;
|
|
1034
|
-
}>();
|
|
1035
|
-
|
|
1036
|
-
return (response.data || []).reduce(
|
|
1037
|
-
(total: number, row: JSONObject): number => {
|
|
1038
|
-
return total + readNumber(row, "chunkStoredBytes");
|
|
1039
|
-
},
|
|
1040
|
-
0,
|
|
1586
|
+
const endTimeUnixMs: number = Math.max(
|
|
1587
|
+
data.header.endTimeUnixMs,
|
|
1588
|
+
data.header.startTimeUnixMs + durationMs,
|
|
1041
1589
|
);
|
|
1590
|
+
|
|
1591
|
+
return {
|
|
1592
|
+
...data.header,
|
|
1593
|
+
durationMs: durationMs,
|
|
1594
|
+
endTime: new Date(endTimeUnixMs),
|
|
1595
|
+
endTimeUnixMs: endTimeUnixMs,
|
|
1596
|
+
chunkCount: Math.max(data.header.chunkCount, data.chunkRowCount),
|
|
1597
|
+
eventCount: Math.max(data.header.eventCount, data.liveEventCount),
|
|
1598
|
+
maxChunkIndex: Math.max(
|
|
1599
|
+
data.header.maxChunkIndex,
|
|
1600
|
+
data.liveMaxChunkIndex,
|
|
1601
|
+
),
|
|
1602
|
+
};
|
|
1042
1603
|
}
|
|
1043
1604
|
|
|
1044
1605
|
/*
|
|
1045
1606
|
* The payload read. The only query in the system that names the
|
|
1046
1607
|
* `payload` column.
|
|
1047
1608
|
*
|
|
1609
|
+
* The byte cap is measured on `length(payload)` - the DECOMPRESSED
|
|
1610
|
+
* stored JSON that is actually returned - in the SAME statement that
|
|
1611
|
+
* ships the bytes, so the column is decompressed once per page rather
|
|
1612
|
+
* than once for a pre-check and again for the read. `payloadBytes` is
|
|
1613
|
+
* the post-gzip WIRE size the recorder uploaded; rrweb JSON gzips
|
|
1614
|
+
* 10-20x, so a cap on it bounds a number an order of magnitude smaller
|
|
1615
|
+
* than the response and therefore bounds nothing useful.
|
|
1616
|
+
*
|
|
1617
|
+
* Prefix semantics rather than refusal. A page that does not fit is
|
|
1618
|
+
* answered with the longest prefix of whole chunks that does, and ALWAYS
|
|
1619
|
+
* with at least the first chunk: the ingest cap
|
|
1620
|
+
* (SESSION_REPLAY_MAX_DECOMPRESSED_FRAME_BYTES) already bounds a single
|
|
1621
|
+
* frame, so a lone chunk can never exceed what the ingest let in, and a
|
|
1622
|
+
* single oversized snapshot that could never be served would dead-end
|
|
1623
|
+
* playback at that chunk forever. The player plans pages against the
|
|
1624
|
+
* wire size it has, requests, and reads back whichever chunks arrived.
|
|
1625
|
+
*
|
|
1048
1626
|
* Both caps are enforced here rather than only at the route so no
|
|
1049
1627
|
* future caller can reach the payload column without them.
|
|
1050
1628
|
*/
|
|
@@ -1055,9 +1633,9 @@ export default class SessionReplayReadService {
|
|
|
1055
1633
|
sessionId: string;
|
|
1056
1634
|
tabId: string;
|
|
1057
1635
|
chunkIndexes: Array<number>;
|
|
1058
|
-
}): Promise<
|
|
1636
|
+
}): Promise<SessionReplayChunkReadResult> {
|
|
1059
1637
|
if (data.chunkIndexes.length === 0) {
|
|
1060
|
-
return [];
|
|
1638
|
+
return { chunks: [], omittedChunkIndexes: [] };
|
|
1061
1639
|
}
|
|
1062
1640
|
|
|
1063
1641
|
if (data.chunkIndexes.length > MAX_SESSION_REPLAY_CHUNKS_PER_READ) {
|
|
@@ -1066,52 +1644,69 @@ export default class SessionReplayReadService {
|
|
|
1066
1644
|
);
|
|
1067
1645
|
}
|
|
1068
1646
|
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1647
|
+
/*
|
|
1648
|
+
* Innermost: the de-duplicated rows. A retried delivery is two
|
|
1649
|
+
* physically present rows on a ReplacingMergeTree until a merge runs.
|
|
1650
|
+
* Feeding both to the player would replay the same mutations twice,
|
|
1651
|
+
* which rrweb resolves against node ids and would either throw or
|
|
1652
|
+
* render a DOM that never existed.
|
|
1653
|
+
*
|
|
1654
|
+
* Middle: a running total of stored bytes in chunk order. Outermost:
|
|
1655
|
+
* a row is SERVED when the total up to and including it is under the
|
|
1656
|
+
* cap, or when it is the first row; a row that is not served keeps
|
|
1657
|
+
* its index (so the caller can name what was omitted) but ships an
|
|
1658
|
+
* empty payload, so the bytes crossing the wire are bounded inside
|
|
1659
|
+
* ClickHouse and never in the application.
|
|
1660
|
+
*
|
|
1661
|
+
* The outer projection is aliased servedPayload rather than payload:
|
|
1662
|
+
* an alias that names the column its own expression reads is a
|
|
1663
|
+
* cyclic alias to ClickHouse.
|
|
1664
|
+
*/
|
|
1078
1665
|
const statement: Statement = SQL`
|
|
1079
1666
|
SELECT
|
|
1080
1667
|
chunkIndex,
|
|
1081
|
-
payload
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1668
|
+
if(isServed, payload, '') AS servedPayload,
|
|
1669
|
+
isServed
|
|
1670
|
+
FROM (
|
|
1671
|
+
SELECT
|
|
1672
|
+
chunkIndex,
|
|
1673
|
+
payload,
|
|
1674
|
+
(sum(length(payload)) OVER (ORDER BY chunkIndex ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) <= ${{
|
|
1675
|
+
type: TableColumnType.Decimal,
|
|
1676
|
+
value: MAX_SESSION_REPLAY_READ_BYTES,
|
|
1677
|
+
}}
|
|
1678
|
+
OR row_number() OVER (ORDER BY chunkIndex ASC) = 1) AS isServed
|
|
1679
|
+
FROM (
|
|
1680
|
+
SELECT
|
|
1681
|
+
chunkIndex,
|
|
1682
|
+
payload
|
|
1683
|
+
FROM ${AnalyticsTableName.RumSessionChunk}
|
|
1684
|
+
WHERE projectId = ${{
|
|
1685
|
+
type: TableColumnType.ObjectID,
|
|
1686
|
+
value: data.projectId,
|
|
1687
|
+
}}
|
|
1688
|
+
AND rumApplicationId = ${{
|
|
1689
|
+
type: TableColumnType.ObjectID,
|
|
1690
|
+
value: data.rumApplicationId,
|
|
1691
|
+
}}
|
|
1692
|
+
AND sessionId = ${{
|
|
1693
|
+
type: TableColumnType.Text,
|
|
1694
|
+
value: data.sessionId,
|
|
1695
|
+
}}
|
|
1696
|
+
AND tabId = ${{
|
|
1697
|
+
type: TableColumnType.Text,
|
|
1698
|
+
value: data.tabId,
|
|
1699
|
+
}}
|
|
1700
|
+
AND chunkIndex IN (${{
|
|
1701
|
+
type: TableColumnType.Number,
|
|
1702
|
+
value: new Includes(data.chunkIndexes),
|
|
1703
|
+
}})
|
|
1103
1704
|
`;
|
|
1104
1705
|
|
|
1105
1706
|
statement.append(RETENTION_FILTER);
|
|
1106
1707
|
|
|
1107
|
-
/*
|
|
1108
|
-
* A retried delivery is two physically present rows on a
|
|
1109
|
-
* ReplacingMergeTree until a merge runs. Feeding both to the player
|
|
1110
|
-
* would replay the same mutations twice, which rrweb resolves against
|
|
1111
|
-
* node ids and would either throw or render a DOM that never existed.
|
|
1112
|
-
*/
|
|
1113
1708
|
statement.append(
|
|
1114
|
-
" ORDER BY chunkIndex ASC, version DESC LIMIT 1 BY chunkIndex",
|
|
1709
|
+
" ORDER BY chunkIndex ASC, version DESC LIMIT 1 BY chunkIndex\n )\n )\n ORDER BY chunkIndex ASC",
|
|
1115
1710
|
);
|
|
1116
1711
|
|
|
1117
1712
|
statement.append(READ_QUERY_SETTINGS);
|
|
@@ -1124,39 +1719,58 @@ export default class SessionReplayReadService {
|
|
|
1124
1719
|
|
|
1125
1720
|
/*
|
|
1126
1721
|
* The cap is re-applied to the bytes actually being handed back, not
|
|
1127
|
-
* only to what
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1130
|
-
*
|
|
1131
|
-
*
|
|
1132
|
-
* set.
|
|
1722
|
+
* only to what ClickHouse computed: any future change to how stored
|
|
1723
|
+
* size is derived would otherwise silently unbound the response. The
|
|
1724
|
+
* prefix is also re-established here - a served row behind an
|
|
1725
|
+
* unserved one would be a hole the player cannot play across, so the
|
|
1726
|
+
* served set stops at the first omission.
|
|
1133
1727
|
*/
|
|
1134
1728
|
let totalReturnedBytes: number = 0;
|
|
1135
1729
|
const chunks: Array<SessionReplayChunkPayload> = [];
|
|
1730
|
+
const omittedChunkIndexes: Array<number> = [];
|
|
1136
1731
|
|
|
1137
|
-
|
|
1138
|
-
const payload: string = readString(row, "payload");
|
|
1732
|
+
const chunkRows: Array<JSONObject> = response.data || [];
|
|
1139
1733
|
|
|
1140
|
-
|
|
1734
|
+
for (const row of chunkRows) {
|
|
1735
|
+
const chunkIndex: number = readNumber(row, "chunkIndex");
|
|
1736
|
+
const payload: string = readString(row, "servedPayload");
|
|
1737
|
+
const isServed: boolean =
|
|
1738
|
+
row["isServed"] === undefined ? true : readBoolean(row, "isServed");
|
|
1141
1739
|
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1740
|
+
const payloadBytes: number = Buffer.byteLength(payload, "utf8");
|
|
1741
|
+
|
|
1742
|
+
const fits: boolean =
|
|
1743
|
+
chunks.length === 0 ||
|
|
1744
|
+
totalReturnedBytes + payloadBytes <= MAX_SESSION_REPLAY_READ_BYTES;
|
|
1745
|
+
|
|
1746
|
+
if (!isServed || !fits || omittedChunkIndexes.length > 0) {
|
|
1747
|
+
omittedChunkIndexes.push(chunkIndex);
|
|
1748
|
+
continue;
|
|
1146
1749
|
}
|
|
1147
1750
|
|
|
1751
|
+
totalReturnedBytes += payloadBytes;
|
|
1752
|
+
|
|
1148
1753
|
chunks.push({
|
|
1149
|
-
chunkIndex:
|
|
1754
|
+
chunkIndex: chunkIndex,
|
|
1150
1755
|
payload: payload,
|
|
1151
1756
|
});
|
|
1152
1757
|
}
|
|
1153
1758
|
|
|
1154
|
-
return chunks;
|
|
1759
|
+
return { chunks: chunks, omittedChunkIndexes: omittedChunkIndexes };
|
|
1155
1760
|
}
|
|
1156
1761
|
|
|
1157
1762
|
/*
|
|
1158
1763
|
* Sessions that observed a given exception fingerprint.
|
|
1159
1764
|
*
|
|
1765
|
+
* Two sources, one header query. The header's exceptionFingerprints
|
|
1766
|
+
* array is written by the finalizer, so for the first 10+ minutes after
|
|
1767
|
+
* the error - the whole incident, from the reporter's point of view -
|
|
1768
|
+
* the session's header knows nothing about it. The exception instance
|
|
1769
|
+
* table, however, carries the session id of the page that threw, from
|
|
1770
|
+
* the moment the exception is ingested. Those ids are looked up first
|
|
1771
|
+
* (cheap: bloom-indexed fingerprint, bounded window) and OR-ed into the
|
|
1772
|
+
* header predicate, so a live session is found as soon as its error is.
|
|
1773
|
+
*
|
|
1160
1774
|
* hasAny() appears twice on purpose. In the WHERE it is a bloom-pruned
|
|
1161
1775
|
* pre-filter over physical rows; a group survives it if ANY of its rows
|
|
1162
1776
|
* carries the fingerprint, which necessarily includes the case where
|
|
@@ -1164,6 +1778,10 @@ export default class SessionReplayReadService {
|
|
|
1164
1778
|
* drop a true match. The HAVING then re-checks the argMax'd array so a
|
|
1165
1779
|
* fingerprint present only on a superseded row does not produce a false
|
|
1166
1780
|
* positive.
|
|
1781
|
+
*
|
|
1782
|
+
* Always windowed. RumSession is partitioned by day, so without a
|
|
1783
|
+
* window this scanned every partition the project ever wrote on every
|
|
1784
|
+
* exception page load.
|
|
1167
1785
|
*/
|
|
1168
1786
|
@CaptureSpan()
|
|
1169
1787
|
public static async getSessionsForException(data: {
|
|
@@ -1178,6 +1796,8 @@ export default class SessionReplayReadService {
|
|
|
1178
1796
|
accessibleRumApplicationIds: Array<ObjectID> | null;
|
|
1179
1797
|
startTime?: Date | undefined;
|
|
1180
1798
|
endTime?: Date | undefined;
|
|
1799
|
+
/* Pin to the one session the caller already knows threw. */
|
|
1800
|
+
sessionId?: string | undefined;
|
|
1181
1801
|
limit: number;
|
|
1182
1802
|
}): Promise<Array<SessionReplayExceptionSession>> {
|
|
1183
1803
|
if (
|
|
@@ -1192,10 +1812,27 @@ export default class SessionReplayReadService {
|
|
|
1192
1812
|
Math.min(data.limit, MAX_SESSION_REPLAY_FOR_EXCEPTION_LIMIT),
|
|
1193
1813
|
);
|
|
1194
1814
|
|
|
1815
|
+
const endTime: Date = data.endTime || OneUptimeDate.getCurrentDate();
|
|
1816
|
+
const startTime: Date =
|
|
1817
|
+
data.startTime ||
|
|
1818
|
+
OneUptimeDate.addRemoveDays(
|
|
1819
|
+
endTime,
|
|
1820
|
+
-DEFAULT_SESSION_REPLAY_FOR_EXCEPTION_WINDOW_DAYS,
|
|
1821
|
+
);
|
|
1822
|
+
|
|
1823
|
+
const instanceSessionIds: Array<string> =
|
|
1824
|
+
await SessionReplayReadService.getSessionIdsForExceptionInstances({
|
|
1825
|
+
projectId: data.projectId,
|
|
1826
|
+
exceptionFingerprint: data.exceptionFingerprint,
|
|
1827
|
+
startTime: startTime,
|
|
1828
|
+
endTime: endTime,
|
|
1829
|
+
sessionId: data.sessionId,
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1195
1832
|
const selectList: string = toSelectList([
|
|
1196
1833
|
{ alias: "aggStartTime", expression: argMaxDateTime("startTime") },
|
|
1197
1834
|
{ alias: "aggEndTime", expression: argMaxDateTime("endTime") },
|
|
1198
|
-
{ alias: "aggDurationMs", expression:
|
|
1835
|
+
{ alias: "aggDurationMs", expression: LIVE_DURATION_EXPRESSION },
|
|
1199
1836
|
{ alias: "aggHasError", expression: argMaxColumn("hasError") },
|
|
1200
1837
|
{ alias: "aggErrorCount", expression: argMaxNumeric("errorCount") },
|
|
1201
1838
|
/*
|
|
@@ -1247,14 +1884,8 @@ export default class SessionReplayReadService {
|
|
|
1247
1884
|
type: TableColumnType.ObjectID,
|
|
1248
1885
|
value: data.projectId,
|
|
1249
1886
|
}}
|
|
1250
|
-
AND hasAny(exceptionFingerprints, [${{
|
|
1251
|
-
type: TableColumnType.Text,
|
|
1252
|
-
value: data.exceptionFingerprint,
|
|
1253
|
-
}}])
|
|
1254
1887
|
`);
|
|
1255
1888
|
|
|
1256
|
-
statement.append(RETENTION_FILTER);
|
|
1257
|
-
|
|
1258
1889
|
if (data.accessibleRumApplicationIds) {
|
|
1259
1890
|
statement.append(
|
|
1260
1891
|
SQL` AND rumApplicationId IN (${{
|
|
@@ -1264,31 +1895,66 @@ export default class SessionReplayReadService {
|
|
|
1264
1895
|
);
|
|
1265
1896
|
}
|
|
1266
1897
|
|
|
1267
|
-
|
|
1898
|
+
statement.append(
|
|
1899
|
+
SQL` AND startTime >= ${{
|
|
1900
|
+
type: TableColumnType.DateTime64,
|
|
1901
|
+
value: startTime,
|
|
1902
|
+
}} AND startTime <= ${{
|
|
1903
|
+
type: TableColumnType.DateTime64,
|
|
1904
|
+
value: endTime,
|
|
1905
|
+
}}`,
|
|
1906
|
+
);
|
|
1907
|
+
|
|
1908
|
+
statement.append(RETENTION_FILTER);
|
|
1909
|
+
|
|
1910
|
+
if (data.sessionId) {
|
|
1268
1911
|
statement.append(
|
|
1269
|
-
SQL` AND
|
|
1270
|
-
type: TableColumnType.
|
|
1271
|
-
value: data.
|
|
1912
|
+
SQL` AND sessionId = ${{
|
|
1913
|
+
type: TableColumnType.Text,
|
|
1914
|
+
value: data.sessionId,
|
|
1272
1915
|
}}`,
|
|
1273
1916
|
);
|
|
1274
1917
|
}
|
|
1275
1918
|
|
|
1276
|
-
|
|
1919
|
+
statement.append(
|
|
1920
|
+
SQL` AND (hasAny(exceptionFingerprints, [${{
|
|
1921
|
+
type: TableColumnType.Text,
|
|
1922
|
+
value: data.exceptionFingerprint,
|
|
1923
|
+
}}])`,
|
|
1924
|
+
);
|
|
1925
|
+
|
|
1926
|
+
if (instanceSessionIds.length > 0) {
|
|
1277
1927
|
statement.append(
|
|
1278
|
-
SQL`
|
|
1279
|
-
type: TableColumnType.
|
|
1280
|
-
value:
|
|
1281
|
-
}}`,
|
|
1928
|
+
SQL` OR sessionId IN (${{
|
|
1929
|
+
type: TableColumnType.Text,
|
|
1930
|
+
value: new Includes(instanceSessionIds),
|
|
1931
|
+
}})`,
|
|
1282
1932
|
);
|
|
1283
1933
|
}
|
|
1284
1934
|
|
|
1935
|
+
statement.append(")");
|
|
1936
|
+
|
|
1285
1937
|
statement.append(
|
|
1286
1938
|
SQL` GROUP BY projectId, rumApplicationId, sessionId
|
|
1287
|
-
HAVING hasAny(aggExceptionFingerprints, [${{
|
|
1939
|
+
HAVING (hasAny(aggExceptionFingerprints, [${{
|
|
1288
1940
|
type: TableColumnType.Text,
|
|
1289
1941
|
value: data.exceptionFingerprint,
|
|
1290
|
-
}}])
|
|
1291
|
-
|
|
1942
|
+
}}])`,
|
|
1943
|
+
);
|
|
1944
|
+
|
|
1945
|
+
if (instanceSessionIds.length > 0) {
|
|
1946
|
+
statement.append(
|
|
1947
|
+
SQL` OR sessionId IN (${{
|
|
1948
|
+
type: TableColumnType.Text,
|
|
1949
|
+
value: new Includes(instanceSessionIds),
|
|
1950
|
+
}})`,
|
|
1951
|
+
);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
statement.append(")");
|
|
1955
|
+
|
|
1956
|
+
statement.append(
|
|
1957
|
+
SQL` ORDER BY aggStartTime DESC
|
|
1292
1958
|
LIMIT ${{
|
|
1293
1959
|
type: TableColumnType.Number,
|
|
1294
1960
|
value: limit,
|
|
@@ -1328,9 +1994,344 @@ export default class SessionReplayReadService {
|
|
|
1328
1994
|
);
|
|
1329
1995
|
}
|
|
1330
1996
|
|
|
1997
|
+
/*
|
|
1998
|
+
* Session ids of the pages that threw this exception, from the
|
|
1999
|
+
* exception instance table. The instance's `time` sits inside its
|
|
2000
|
+
* session, so a session that started inside the window threw inside
|
|
2001
|
+
* [startTime, endTime + max session length].
|
|
2002
|
+
*
|
|
2003
|
+
* Best-effort: the side index only ADDS live sessions to the answer, so
|
|
2004
|
+
* a failure here degrades to the finalized-only lookup with a warning
|
|
2005
|
+
* rather than failing the exception page's replay card.
|
|
2006
|
+
*
|
|
2007
|
+
* A caller-pinned sessionId narrows the lookup rather than bypassing it,
|
|
2008
|
+
* so the pin can never assert that a session threw something the
|
|
2009
|
+
* instance table has no record of it throwing.
|
|
2010
|
+
*/
|
|
2011
|
+
private static async getSessionIdsForExceptionInstances(data: {
|
|
2012
|
+
projectId: ObjectID;
|
|
2013
|
+
exceptionFingerprint: string;
|
|
2014
|
+
startTime: Date;
|
|
2015
|
+
endTime: Date;
|
|
2016
|
+
sessionId?: string | undefined;
|
|
2017
|
+
}): Promise<Array<string>> {
|
|
2018
|
+
const statement: Statement = SQL`
|
|
2019
|
+
SELECT DISTINCT sessionId
|
|
2020
|
+
FROM ${AnalyticsTableName.ExceptionInstance}
|
|
2021
|
+
WHERE projectId = ${{
|
|
2022
|
+
type: TableColumnType.ObjectID,
|
|
2023
|
+
value: data.projectId,
|
|
2024
|
+
}}
|
|
2025
|
+
AND fingerprint = ${{
|
|
2026
|
+
type: TableColumnType.Text,
|
|
2027
|
+
value: data.exceptionFingerprint,
|
|
2028
|
+
}}
|
|
2029
|
+
AND sessionId != ''
|
|
2030
|
+
AND time >= ${{
|
|
2031
|
+
type: TableColumnType.DateTime64,
|
|
2032
|
+
value: data.startTime,
|
|
2033
|
+
}}
|
|
2034
|
+
AND time <= ${{
|
|
2035
|
+
type: TableColumnType.DateTime64,
|
|
2036
|
+
value: new Date(
|
|
2037
|
+
data.endTime.getTime() + SESSION_REPLAY_MAX_SESSION_MS,
|
|
2038
|
+
),
|
|
2039
|
+
}}
|
|
2040
|
+
`;
|
|
2041
|
+
|
|
2042
|
+
/*
|
|
2043
|
+
* A pinned sessionId narrows this lookup; it does NOT replace it.
|
|
2044
|
+
*
|
|
2045
|
+
* Returning the pinned id unchecked made the caller's statement read
|
|
2046
|
+
* `sessionId = X AND (hasAny(fingerprints, [f]) OR sessionId IN (X))`,
|
|
2047
|
+
* whose second arm is trivially true - so the fingerprint constrained
|
|
2048
|
+
* nothing and the "Watch what the user saw" card would present any
|
|
2049
|
+
* accessible session as having observed this exception, on nothing but
|
|
2050
|
+
* a stale occurrence row. Asking the instance table whether THAT
|
|
2051
|
+
* session threw THIS fingerprint keeps the pin's real purpose (a live
|
|
2052
|
+
* session whose header has no fingerprints yet) while keeping the
|
|
2053
|
+
* claim true. A failure here answers [] and the header's mandatory
|
|
2054
|
+
* hasAny() predicate decides alone - fail closed.
|
|
2055
|
+
*/
|
|
2056
|
+
if (data.sessionId) {
|
|
2057
|
+
statement.append(
|
|
2058
|
+
SQL` AND sessionId = ${{
|
|
2059
|
+
type: TableColumnType.Text,
|
|
2060
|
+
value: data.sessionId,
|
|
2061
|
+
}}`,
|
|
2062
|
+
);
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
statement.append(SQL`
|
|
2066
|
+
ORDER BY sessionId ASC
|
|
2067
|
+
LIMIT ${{
|
|
2068
|
+
type: TableColumnType.Number,
|
|
2069
|
+
value: MAX_EXCEPTION_INSTANCE_SESSION_IDS,
|
|
2070
|
+
}}
|
|
2071
|
+
`);
|
|
2072
|
+
|
|
2073
|
+
statement.append(READ_QUERY_SETTINGS);
|
|
2074
|
+
|
|
2075
|
+
try {
|
|
2076
|
+
const dbResult: Results =
|
|
2077
|
+
await ExceptionInstanceService.executeQuery(statement);
|
|
2078
|
+
const response: DbJSONResponse = await dbResult.json<{
|
|
2079
|
+
data?: Array<JSONObject>;
|
|
2080
|
+
}>();
|
|
2081
|
+
|
|
2082
|
+
return (response.data || [])
|
|
2083
|
+
.map((row: JSONObject): string => {
|
|
2084
|
+
return readString(row, "sessionId");
|
|
2085
|
+
})
|
|
2086
|
+
.filter((sessionId: string): boolean => {
|
|
2087
|
+
return sessionId.length > 0;
|
|
2088
|
+
});
|
|
2089
|
+
} catch (err: unknown) {
|
|
2090
|
+
logger.warn(
|
|
2091
|
+
"SessionReplayReadService: could not look up exception instances by session; answering from finalized headers only",
|
|
2092
|
+
);
|
|
2093
|
+
logger.warn(err);
|
|
2094
|
+
|
|
2095
|
+
return [];
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
/*
|
|
2100
|
+
* Recording activity for one application over the last 24 hours, for
|
|
2101
|
+
* the health surface. No GROUP BY and no payload: uniqExact over the
|
|
2102
|
+
* sort-key range for the counts, and an ORDER BY startTime DESC LIMIT 1
|
|
2103
|
+
* (read in sort-key order, stops after one granule) for the most recent
|
|
2104
|
+
* start, which is NOT bounded to 24h so "the most recent was 3 days
|
|
2105
|
+
* ago" can be said when today is quiet.
|
|
2106
|
+
*
|
|
2107
|
+
* "Playable" is counted by subtraction: a session is unplayable only
|
|
2108
|
+
* when its FINALIZED row says it holds no chunks or was sealed as
|
|
2109
|
+
* recording-lost. Every other session - live, or finalized with footage
|
|
2110
|
+
* - can be watched. Counted that way because a finalized session still
|
|
2111
|
+
* has its provisional row on disk until a merge runs, and that row
|
|
2112
|
+
* would otherwise count a lost recording as live.
|
|
2113
|
+
*
|
|
2114
|
+
* ClickHouse trouble answers null (the UI says "unknown"), never 0:
|
|
2115
|
+
* "no sessions" and "could not count" are different diagnoses.
|
|
2116
|
+
*/
|
|
2117
|
+
@CaptureSpan()
|
|
2118
|
+
public static async getApplicationActivitySummary(data: {
|
|
2119
|
+
projectId: ObjectID;
|
|
2120
|
+
rumApplicationId: ObjectID;
|
|
2121
|
+
nowUnixMs?: number | undefined;
|
|
2122
|
+
}): Promise<SessionReplayApplicationActivitySummary> {
|
|
2123
|
+
const nowUnixMs: number = data.nowUnixMs ?? Date.now();
|
|
2124
|
+
const cacheKey: string = `${data.projectId.toString()}:${data.rumApplicationId.toString()}`;
|
|
2125
|
+
|
|
2126
|
+
const cached: ActivitySummaryCacheEntry | undefined =
|
|
2127
|
+
activitySummaryCache.get(cacheKey);
|
|
2128
|
+
|
|
2129
|
+
if (cached && cached.expiresAt > nowUnixMs) {
|
|
2130
|
+
return cached.summary;
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
const summary: SessionReplayApplicationActivitySummary =
|
|
2134
|
+
await SessionReplayReadService.readApplicationActivitySummary({
|
|
2135
|
+
projectId: data.projectId,
|
|
2136
|
+
rumApplicationId: data.rumApplicationId,
|
|
2137
|
+
nowUnixMs: nowUnixMs,
|
|
2138
|
+
});
|
|
2139
|
+
|
|
2140
|
+
/*
|
|
2141
|
+
* Coarse LRU: evict the oldest entry when full and the key is new, so
|
|
2142
|
+
* a burst of distinct applications cannot grow the map without bound.
|
|
2143
|
+
*/
|
|
2144
|
+
if (
|
|
2145
|
+
activitySummaryCache.size >= MAX_ACTIVITY_SUMMARY_CACHE_ENTRIES &&
|
|
2146
|
+
!activitySummaryCache.has(cacheKey)
|
|
2147
|
+
) {
|
|
2148
|
+
const oldest: string | undefined = activitySummaryCache
|
|
2149
|
+
.keys()
|
|
2150
|
+
.next().value;
|
|
2151
|
+
|
|
2152
|
+
if (oldest !== undefined) {
|
|
2153
|
+
activitySummaryCache.delete(oldest);
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
activitySummaryCache.delete(cacheKey);
|
|
2158
|
+
activitySummaryCache.set(cacheKey, {
|
|
2159
|
+
summary: summary,
|
|
2160
|
+
expiresAt: nowUnixMs + SESSION_REPLAY_ACTIVITY_SUMMARY_CACHE_TTL_MS,
|
|
2161
|
+
});
|
|
2162
|
+
|
|
2163
|
+
return summary;
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
private static async readApplicationActivitySummary(data: {
|
|
2167
|
+
projectId: ObjectID;
|
|
2168
|
+
rumApplicationId: ObjectID;
|
|
2169
|
+
nowUnixMs: number;
|
|
2170
|
+
}): Promise<SessionReplayApplicationActivitySummary> {
|
|
2171
|
+
const countsStatement: Statement = SQL`
|
|
2172
|
+
SELECT
|
|
2173
|
+
toFloat64(uniqExact(sessionId)) AS sessionCount,
|
|
2174
|
+
toFloat64(uniqExactIf(sessionId, isFinalized AND (chunkCount = 0 OR sealedReason = ${{
|
|
2175
|
+
type: TableColumnType.Text,
|
|
2176
|
+
value: SessionReplaySealedReason.RecordingLost,
|
|
2177
|
+
}}))) AS unplayableCount
|
|
2178
|
+
FROM ${AnalyticsTableName.RumSession}
|
|
2179
|
+
WHERE projectId = ${{
|
|
2180
|
+
type: TableColumnType.ObjectID,
|
|
2181
|
+
value: data.projectId,
|
|
2182
|
+
}}
|
|
2183
|
+
AND rumApplicationId = ${{
|
|
2184
|
+
type: TableColumnType.ObjectID,
|
|
2185
|
+
value: data.rumApplicationId,
|
|
2186
|
+
}}
|
|
2187
|
+
AND startTime >= ${{
|
|
2188
|
+
type: TableColumnType.DateTime64,
|
|
2189
|
+
value: new Date(data.nowUnixMs - 24 * 60 * 60 * 1000),
|
|
2190
|
+
}}
|
|
2191
|
+
`;
|
|
2192
|
+
|
|
2193
|
+
countsStatement.append(RETENTION_FILTER);
|
|
2194
|
+
countsStatement.append(READ_QUERY_SETTINGS);
|
|
2195
|
+
|
|
2196
|
+
const lastStartStatement: Statement = SQL`
|
|
2197
|
+
SELECT
|
|
2198
|
+
toFloat64(toUnixTimestamp64Milli(startTime)) AS lastStartUnixMs,
|
|
2199
|
+
/*
|
|
2200
|
+
* The newest session's recorder capabilities, read off the same row
|
|
2201
|
+
* that answers "when did recording last start". Named directly (not
|
|
2202
|
+
* through the argMax alias set) because this statement has no GROUP
|
|
2203
|
+
* BY: it is one row, read in sort-key order, LIMIT 1.
|
|
2204
|
+
*/
|
|
2205
|
+
attributes AS aggAttributes
|
|
2206
|
+
FROM ${AnalyticsTableName.RumSession}
|
|
2207
|
+
WHERE projectId = ${{
|
|
2208
|
+
type: TableColumnType.ObjectID,
|
|
2209
|
+
value: data.projectId,
|
|
2210
|
+
}}
|
|
2211
|
+
AND rumApplicationId = ${{
|
|
2212
|
+
type: TableColumnType.ObjectID,
|
|
2213
|
+
value: data.rumApplicationId,
|
|
2214
|
+
}}
|
|
2215
|
+
`;
|
|
2216
|
+
|
|
2217
|
+
lastStartStatement.append(RETENTION_FILTER);
|
|
2218
|
+
lastStartStatement.append(" ORDER BY startTime DESC LIMIT 1");
|
|
2219
|
+
lastStartStatement.append(READ_QUERY_SETTINGS);
|
|
2220
|
+
|
|
2221
|
+
try {
|
|
2222
|
+
const [countsResult, lastStartResult]: [Results, Results] =
|
|
2223
|
+
await Promise.all([
|
|
2224
|
+
RumSessionService.executeQuery(countsStatement),
|
|
2225
|
+
RumSessionService.executeQuery(lastStartStatement),
|
|
2226
|
+
]);
|
|
2227
|
+
|
|
2228
|
+
const countsResponse: DbJSONResponse = await countsResult.json<{
|
|
2229
|
+
data?: Array<JSONObject>;
|
|
2230
|
+
}>();
|
|
2231
|
+
const lastStartResponse: DbJSONResponse = await lastStartResult.json<{
|
|
2232
|
+
data?: Array<JSONObject>;
|
|
2233
|
+
}>();
|
|
2234
|
+
|
|
2235
|
+
const countsRow: JSONObject | undefined = (countsResponse.data || [])[0];
|
|
2236
|
+
const lastStartRow: JSONObject | undefined = (lastStartResponse.data ||
|
|
2237
|
+
[])[0];
|
|
2238
|
+
|
|
2239
|
+
const sessionCount: number = countsRow
|
|
2240
|
+
? readNumber(countsRow, "sessionCount")
|
|
2241
|
+
: 0;
|
|
2242
|
+
const unplayableCount: number = countsRow
|
|
2243
|
+
? readNumber(countsRow, "unplayableCount")
|
|
2244
|
+
: 0;
|
|
2245
|
+
|
|
2246
|
+
const lastStartUnixMs: number = lastStartRow
|
|
2247
|
+
? readNumber(lastStartRow, "lastStartUnixMs")
|
|
2248
|
+
: 0;
|
|
2249
|
+
|
|
2250
|
+
/*
|
|
2251
|
+
* An empty list means "the newest session declared none" (an old
|
|
2252
|
+
* recorder artifact), which is not the same as "we could not tell" -
|
|
2253
|
+
* but the health copy renders both as "not reported yet", and
|
|
2254
|
+
* claiming a recorder has NO capabilities would be a stronger
|
|
2255
|
+
* statement than the row supports. So an empty list answers null and
|
|
2256
|
+
* only a non-empty one is reported.
|
|
2257
|
+
*/
|
|
2258
|
+
const recorderCapabilities: Array<string> = lastStartRow
|
|
2259
|
+
? readRecorderCapabilities(lastStartRow)
|
|
2260
|
+
: [];
|
|
2261
|
+
|
|
2262
|
+
return {
|
|
2263
|
+
sessionsLast24h: sessionCount,
|
|
2264
|
+
playableSessionsLast24h: Math.max(0, sessionCount - unplayableCount),
|
|
2265
|
+
lastSessionStartedAt:
|
|
2266
|
+
lastStartUnixMs > 0 ? new Date(lastStartUnixMs) : null,
|
|
2267
|
+
recorderCapabilities:
|
|
2268
|
+
recorderCapabilities.length > 0 ? recorderCapabilities : null,
|
|
2269
|
+
};
|
|
2270
|
+
} catch (err: unknown) {
|
|
2271
|
+
logger.warn(
|
|
2272
|
+
"SessionReplayReadService: could not read the application activity summary",
|
|
2273
|
+
);
|
|
2274
|
+
logger.warn(err);
|
|
2275
|
+
|
|
2276
|
+
return {
|
|
2277
|
+
sessionsLast24h: null,
|
|
2278
|
+
playableSessionsLast24h: null,
|
|
2279
|
+
lastSessionStartedAt: null,
|
|
2280
|
+
recorderCapabilities: null,
|
|
2281
|
+
};
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
/* The HAVING/ORDER BY expression for a sort key. */
|
|
2286
|
+
private static getSortExpression(sortBy: SessionReplaySortBy): string {
|
|
2287
|
+
switch (sortBy) {
|
|
2288
|
+
case "durationMs":
|
|
2289
|
+
return "aggDurationMs";
|
|
2290
|
+
case "errorCount":
|
|
2291
|
+
return "aggErrorCount";
|
|
2292
|
+
case "frustration":
|
|
2293
|
+
return FRUSTRATION_TOTAL_EXPRESSION;
|
|
2294
|
+
case "startTime":
|
|
2295
|
+
default:
|
|
2296
|
+
return "aggStartTime";
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
/* The cursor value of a row under a sort key: what the expression above yields. */
|
|
2301
|
+
private static getSortValue(
|
|
2302
|
+
sortBy: SessionReplaySortBy,
|
|
2303
|
+
item: SessionReplayListItem,
|
|
2304
|
+
): number {
|
|
2305
|
+
switch (sortBy) {
|
|
2306
|
+
case "durationMs":
|
|
2307
|
+
return item.durationMs;
|
|
2308
|
+
case "errorCount":
|
|
2309
|
+
return item.errorCount;
|
|
2310
|
+
case "frustration":
|
|
2311
|
+
return (
|
|
2312
|
+
item.rageClickCount +
|
|
2313
|
+
item.deadClickCount +
|
|
2314
|
+
item.errorClickCount +
|
|
2315
|
+
item.refreshRageCount
|
|
2316
|
+
);
|
|
2317
|
+
case "startTime":
|
|
2318
|
+
default:
|
|
2319
|
+
return item.startTime.getTime();
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
/*
|
|
2324
|
+
* Every list predicate, in cost order: booleans and equality over
|
|
2325
|
+
* aliases first, IN lists next, array membership after, and the
|
|
2326
|
+
* substring predicates (tags, urlPrefix, search) LAST. ClickHouse
|
|
2327
|
+
* evaluates HAVING per group after aggregation, so the order does not
|
|
2328
|
+
* change what is scanned, but a cheap predicate that fails first spares
|
|
2329
|
+
* the string work for every group it eliminates.
|
|
2330
|
+
*/
|
|
1331
2331
|
private static appendListHavingFilters(
|
|
1332
2332
|
statement: Statement,
|
|
1333
2333
|
filters: SessionReplayListFilters,
|
|
2334
|
+
includeIdentifiedUserLabel: boolean,
|
|
1334
2335
|
): void {
|
|
1335
2336
|
if (filters.hasError !== undefined) {
|
|
1336
2337
|
statement.append(
|
|
@@ -1352,11 +2353,10 @@ export default class SessionReplayReadService {
|
|
|
1352
2353
|
* honour false, so accepting the value and ignoring it returned the
|
|
1353
2354
|
* whole unfiltered list with a 200 and no indication why.
|
|
1354
2355
|
*/
|
|
1355
|
-
const total: string =
|
|
1356
|
-
"(aggRageClickCount + aggDeadClickCount + aggErrorClickCount + aggRefreshRageCount)";
|
|
1357
|
-
|
|
1358
2356
|
statement.append(
|
|
1359
|
-
filters.hasFrustration
|
|
2357
|
+
filters.hasFrustration
|
|
2358
|
+
? ` AND ${FRUSTRATION_TOTAL_EXPRESSION} > 0`
|
|
2359
|
+
: ` AND ${FRUSTRATION_TOTAL_EXPRESSION} = 0`,
|
|
1360
2360
|
);
|
|
1361
2361
|
}
|
|
1362
2362
|
|
|
@@ -1369,6 +2369,37 @@ export default class SessionReplayReadService {
|
|
|
1369
2369
|
);
|
|
1370
2370
|
}
|
|
1371
2371
|
|
|
2372
|
+
if (filters.hasIdentifiedUser !== undefined) {
|
|
2373
|
+
/*
|
|
2374
|
+
* The digest column, not the label: it is under the ordinary session
|
|
2375
|
+
* ACL, and "did somebody identify" discloses nothing about who.
|
|
2376
|
+
*/
|
|
2377
|
+
statement.append(
|
|
2378
|
+
filters.hasIdentifiedUser
|
|
2379
|
+
? " AND aggIdentifiedUserKey != ''"
|
|
2380
|
+
: " AND aggIdentifiedUserKey = ''",
|
|
2381
|
+
);
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
if (filters.isPlayable !== undefined) {
|
|
2385
|
+
/*
|
|
2386
|
+
* A live session is playable (its chunks are being written); a
|
|
2387
|
+
* finalized one only when the finalizer counted chunks and did not
|
|
2388
|
+
* seal it as lost.
|
|
2389
|
+
*/
|
|
2390
|
+
const playable: string = `((aggIsFinalized = 0 OR aggChunkCount > 0) AND aggSealedReason != '${SessionReplaySealedReason.RecordingLost}')`;
|
|
2391
|
+
|
|
2392
|
+
statement.append(
|
|
2393
|
+
filters.isPlayable ? ` AND ${playable}` : ` AND NOT ${playable}`,
|
|
2394
|
+
);
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
if (filters.hasTraces !== undefined) {
|
|
2398
|
+
statement.append(
|
|
2399
|
+
filters.hasTraces ? " AND aggTraceCount > 0" : " AND aggTraceCount = 0",
|
|
2400
|
+
);
|
|
2401
|
+
}
|
|
2402
|
+
|
|
1372
2403
|
if (filters.triggerReasons && filters.triggerReasons.length > 0) {
|
|
1373
2404
|
statement.append(
|
|
1374
2405
|
SQL` AND aggTriggerReason IN (${{
|
|
@@ -1425,16 +2456,10 @@ export default class SessionReplayReadService {
|
|
|
1425
2456
|
|
|
1426
2457
|
if (filters.route) {
|
|
1427
2458
|
/*
|
|
1428
|
-
* `has`, not a Search/LIKE
|
|
1429
|
-
*
|
|
1430
|
-
* performance fix" - a lowerUTF8 arrayExists has no bloom
|
|
1431
|
-
* pre-filter and full-scans the table.
|
|
1432
|
-
*
|
|
1433
|
-
* The argMax expression is appended as raw SQL rather than
|
|
1434
|
-
* interpolated into the template: a plain string substituted into
|
|
1435
|
-
* an SQL`` literal is bound as an Identifier and would be quoted.
|
|
2459
|
+
* `has`, not a Search/LIKE: exact membership is the cheap array
|
|
2460
|
+
* path. Over the argMax alias, never the raw column.
|
|
1436
2461
|
*/
|
|
1437
|
-
statement.append(
|
|
2462
|
+
statement.append(" AND has(aggRoutes, ");
|
|
1438
2463
|
statement.append(
|
|
1439
2464
|
SQL`${{
|
|
1440
2465
|
type: TableColumnType.Text,
|
|
@@ -1454,5 +2479,121 @@ export default class SessionReplayReadService {
|
|
|
1454
2479
|
}}`,
|
|
1455
2480
|
);
|
|
1456
2481
|
}
|
|
2482
|
+
|
|
2483
|
+
if (filters.tags) {
|
|
2484
|
+
/*
|
|
2485
|
+
* Every pair must match. mapContains first so an absent key never
|
|
2486
|
+
* matches the empty string a Map subscript returns for it. Bounded
|
|
2487
|
+
* by the number of tags a session can even carry.
|
|
2488
|
+
*/
|
|
2489
|
+
const pairs: Array<[string, string]> = Object.entries(filters.tags)
|
|
2490
|
+
.filter(([key, value]: [string, string]): boolean => {
|
|
2491
|
+
return key.length > 0 && typeof value === "string";
|
|
2492
|
+
})
|
|
2493
|
+
.slice(0, SESSION_REPLAY_MAX_TAG_KEYS);
|
|
2494
|
+
|
|
2495
|
+
for (const [key, value] of pairs) {
|
|
2496
|
+
statement.append(" AND mapContains(aggTags, ");
|
|
2497
|
+
statement.append(
|
|
2498
|
+
SQL`${{
|
|
2499
|
+
type: TableColumnType.Text,
|
|
2500
|
+
value: key,
|
|
2501
|
+
}}) AND aggTags[${{
|
|
2502
|
+
type: TableColumnType.Text,
|
|
2503
|
+
value: key,
|
|
2504
|
+
}}] = ${{
|
|
2505
|
+
type: TableColumnType.Text,
|
|
2506
|
+
value: value,
|
|
2507
|
+
}}`,
|
|
2508
|
+
);
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
if (filters.urlPrefix) {
|
|
2513
|
+
/*
|
|
2514
|
+
* "sessions that touched /checkout/*": a prefix over every route the
|
|
2515
|
+
* session visited and over the entry URL, which for a pre-migration
|
|
2516
|
+
* session is the only URL the header holds.
|
|
2517
|
+
*
|
|
2518
|
+
* The stored values are scrubbed ABSOLUTE urls (https://host/path),
|
|
2519
|
+
* but the filter a human types is a PATH - the search box routes any
|
|
2520
|
+
* value beginning with "/" here, and the docs promise `url:/checkout`
|
|
2521
|
+
* outright. Matching only the full string meant that documented
|
|
2522
|
+
* search never matched anything, in any project, with no error to
|
|
2523
|
+
* say so. So the path of each route and of the entry URL is matched
|
|
2524
|
+
* as well as the whole URL: an absolute prefix still matches on the
|
|
2525
|
+
* first arm, a path prefix on the second. ClickHouse's path() returns
|
|
2526
|
+
* the path component without host or query, which is exactly the
|
|
2527
|
+
* shape the recorder's route list is scrubbed down to.
|
|
2528
|
+
*/
|
|
2529
|
+
const prefixParameter: { type: TableColumnType; value: string } = {
|
|
2530
|
+
type: TableColumnType.Text,
|
|
2531
|
+
value: filters.urlPrefix,
|
|
2532
|
+
};
|
|
2533
|
+
|
|
2534
|
+
statement.append(" AND (arrayExists(r -> startsWith(r, ");
|
|
2535
|
+
statement.append(SQL`${prefixParameter}`);
|
|
2536
|
+
statement.append(") OR startsWith(path(r), ");
|
|
2537
|
+
statement.append(SQL`${prefixParameter}`);
|
|
2538
|
+
statement.append("), aggRoutes) OR startsWith(aggEntryUrl, ");
|
|
2539
|
+
statement.append(SQL`${prefixParameter}`);
|
|
2540
|
+
statement.append(") OR startsWith(path(aggEntryUrl), ");
|
|
2541
|
+
statement.append(SQL`${prefixParameter}`);
|
|
2542
|
+
statement.append("))");
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
if (filters.search) {
|
|
2546
|
+
SessionReplayReadService.appendSearchPredicate(
|
|
2547
|
+
statement,
|
|
2548
|
+
filters.search,
|
|
2549
|
+
includeIdentifiedUserLabel,
|
|
2550
|
+
);
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
/*
|
|
2555
|
+
* Free-text search, last of the predicates because it is the only one
|
|
2556
|
+
* that does substring work per group. The identified user label is
|
|
2557
|
+
* searched ONLY when the caller may read it: without that gate a caller
|
|
2558
|
+
* denied the label could ask "is jane@example.com here" and read every
|
|
2559
|
+
* other field of the answer.
|
|
2560
|
+
*/
|
|
2561
|
+
private static appendSearchPredicate(
|
|
2562
|
+
statement: Statement,
|
|
2563
|
+
search: string,
|
|
2564
|
+
includeIdentifiedUserLabel: boolean,
|
|
2565
|
+
): void {
|
|
2566
|
+
const term: string = search
|
|
2567
|
+
.trim()
|
|
2568
|
+
.substring(0, SESSION_REPLAY_LIST_SEARCH_MAX_LENGTH);
|
|
2569
|
+
|
|
2570
|
+
if (!term) {
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
const textParameter: { type: TableColumnType; value: string } = {
|
|
2575
|
+
type: TableColumnType.Text,
|
|
2576
|
+
value: term,
|
|
2577
|
+
};
|
|
2578
|
+
|
|
2579
|
+
statement.append(" AND (startsWith(sessionId, ");
|
|
2580
|
+
statement.append(SQL`${textParameter})`);
|
|
2581
|
+
statement.append(" OR positionCaseInsensitiveUTF8(aggEntryUrl, ");
|
|
2582
|
+
statement.append(SQL`${textParameter}) > 0`);
|
|
2583
|
+
statement.append(" OR positionCaseInsensitiveUTF8(aggExitUrl, ");
|
|
2584
|
+
statement.append(SQL`${textParameter}) > 0`);
|
|
2585
|
+
statement.append(" OR arrayExists(r -> positionCaseInsensitiveUTF8(r, ");
|
|
2586
|
+
statement.append(SQL`${textParameter}) > 0, aggRoutes)`);
|
|
2587
|
+
statement.append(` OR has(${argMaxColumn("traceIds")}, `);
|
|
2588
|
+
statement.append(SQL`${textParameter})`);
|
|
2589
|
+
|
|
2590
|
+
if (includeIdentifiedUserLabel) {
|
|
2591
|
+
statement.append(
|
|
2592
|
+
" OR positionCaseInsensitiveUTF8(aggIdentifiedUserLabel, ",
|
|
2593
|
+
);
|
|
2594
|
+
statement.append(SQL`${textParameter}) > 0`);
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
statement.append(")");
|
|
1457
2598
|
}
|
|
1458
2599
|
}
|