@agent-native/core-corpus 0.216.0 → 0.218.0
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/corpus/README.md +1 -1
- package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +61 -1
- package/corpus/templates/analytics/AGENTS.md +1 -1
- package/corpus/templates/analytics/CHANGELOG.md +3 -0
- package/corpus/templates/analytics/actions/get-session-replay-summary.ts +9 -65
- package/corpus/templates/analytics/actions/list-event-catalog.ts +1 -1
- package/corpus/templates/analytics/actions/list-session-outputs.ts +40 -0
- package/corpus/templates/analytics/actions/list-session-recordings.ts +17 -4
- package/corpus/templates/analytics/actions/view-screen.ts +29 -3
- package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +38 -167
- package/corpus/templates/analytics/app/i18n/en-US.ts +27 -0
- package/corpus/templates/analytics/app/pages/sessions/EventCatalogPage.tsx +18 -1
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +3 -1
- package/corpus/templates/analytics/app/pages/sessions/SessionOutputs.tsx +197 -0
- package/corpus/templates/analytics/app/pages/sessions/SessionsTriagePage.tsx +114 -4
- package/corpus/templates/analytics/changelog/2026-10-15-sessions-can-find-people-who-made-a-slides-deck-but-didn-t-p.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-16-the-activation-funnel-counts-visitors-who-decline-analytics-.md +6 -0
- package/corpus/templates/analytics/server/db/schema.ts +67 -0
- package/corpus/templates/analytics/server/lib/canonical-first-party-dashboard-repair.ts +9 -0
- package/corpus/templates/analytics/server/lib/first-party-analytics.ts +28 -22
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +36 -1
- package/corpus/templates/analytics/server/lib/output-outcomes.ts +712 -0
- package/corpus/templates/analytics/server/lib/replay-origins.ts +33 -0
- package/corpus/templates/analytics/server/lib/session-event-index.ts +20 -0
- package/corpus/templates/analytics/server/lib/session-performance.ts +6 -40
- package/corpus/templates/analytics/server/lib/session-recording-associations.ts +30 -1
- package/corpus/templates/analytics/server/lib/session-replay-triage.ts +99 -0
- package/corpus/templates/analytics/server/lib/session-replay.ts +45 -31
- package/corpus/templates/analytics/server/lib/sessions-triage-lab.ts +8 -2
- package/corpus/templates/analytics/server/plugins/db.ts +46 -0
- package/corpus/templates/analytics/shared/output-use.ts +205 -0
- package/corpus/templates/analytics/shared/session-events.ts +6 -0
- package/corpus/templates/assets/CHANGELOG.md +6 -0
- package/corpus/templates/assets/app/components/layout/Sidebar.tsx +51 -117
- package/corpus/templates/assets/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
- package/corpus/templates/brain/app/components/layout/Sidebar.tsx +34 -101
- package/corpus/templates/brain/app/routes/sources.tsx +1 -1
- package/corpus/templates/brain/vitest.config.ts +7 -0
- package/corpus/templates/calendar/CHANGELOG.md +12 -0
- package/corpus/templates/calendar/actions/create-event.ts +2 -4
- package/corpus/templates/calendar/actions/update-event.ts +35 -6
- package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +1 -1
- package/corpus/templates/calendar/changelog/2026-10-16-booking-links-show-localized-visibility-badges.md +6 -0
- package/corpus/templates/calendar/changelog/2026-10-21-solo-events-avoid-notification-approval.md +6 -0
- package/corpus/templates/chat/app/components/layout/Sidebar.tsx +62 -101
- package/corpus/templates/clips/CHANGELOG.md +7 -20
- package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +20 -77
- package/corpus/templates/clips/app/components/player/share-dialog.tsx +63 -90
- package/corpus/templates/clips/app/components/sharing/share-test-support.tsx +180 -0
- package/corpus/templates/clips/app/components/sharing/share-ui.tsx +5 -584
- package/corpus/templates/clips/changelog/2026-10-16-share-dialogs-make-it-easier-to-see-who-has-access-and-manag.md +6 -0
- package/corpus/templates/clips/changelog/2026-10-21-share-panels-describe-general-access-in-a-sentence.md +6 -0
- package/corpus/templates/community/qr/package.json +1 -1
- package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +6 -3
- package/corpus/templates/content/CHANGELOG.md +10 -0
- package/corpus/templates/content/actions/_browser-document-save-attempt.ts +23 -1
- package/corpus/templates/content/actions/_builder-cms-blob-custody.ts +467 -0
- package/corpus/templates/content/actions/_builder-cms-execution-lease.ts +17 -0
- package/corpus/templates/content/actions/_builder-cms-intent-lookup.ts +5 -0
- package/corpus/templates/content/actions/_builder-cms-read-client.ts +881 -21
- package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +28 -2
- package/corpus/templates/content/actions/_builder-cms-write-client.ts +109 -11
- package/corpus/templates/content/actions/_builder-docs-client.ts +1 -0
- package/corpus/templates/content/actions/_builder-private-blob.test-fixture.ts +90 -0
- package/corpus/templates/content/actions/_builder-source-execution-guard.ts +48 -0
- package/corpus/templates/content/actions/_content-save-outcomes.ts +5 -0
- package/corpus/templates/content/actions/_database-source-utils.ts +290 -8
- package/corpus/templates/content/actions/_database-utils.ts +93 -7
- package/corpus/templates/content/actions/_database-view-setup.ts +21 -2
- package/corpus/templates/content/actions/_delete-content-space.ts +8 -1
- package/corpus/templates/content/actions/_github-source-freshness.ts +30 -4
- package/corpus/templates/content/actions/add-content-database-source-field-property.ts +23 -6
- package/corpus/templates/content/actions/cancel-prepared-builder-source-update.ts +110 -26
- package/corpus/templates/content/actions/change-content-database-source-role.ts +51 -7
- package/corpus/templates/content/actions/delete-document.ts +41 -3
- package/corpus/templates/content/actions/disconnect-content-database-source.ts +68 -2
- package/corpus/templates/content/actions/disconnect-local-folder-source.ts +33 -2
- package/corpus/templates/content/actions/execute-builder-source-execution.ts +900 -140
- package/corpus/templates/content/actions/get-document.ts +2 -0
- package/corpus/templates/content/actions/permanently-delete-document.ts +6 -0
- package/corpus/templates/content/actions/prepare-builder-source-execution.ts +155 -13
- package/corpus/templates/content/actions/prepare-builder-source-review.ts +299 -73
- package/corpus/templates/content/actions/set-live-body-switch.ts +45 -0
- package/corpus/templates/content/actions/update-document.ts +139 -20
- package/corpus/templates/content/actions/validate-builder-source-execution.ts +135 -26
- package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +119 -30
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +56 -13
- package/corpus/templates/content/app/components/editor/SuggestionText.tsx +18 -3
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +89 -22
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +23 -0
- package/corpus/templates/content/app/components/editor/database/GitHubSourceStatusLine.tsx +27 -2
- package/corpus/templates/content/app/components/editor/database/ListItemLayout.tsx +32 -8
- package/corpus/templates/content/app/components/editor/database/ListItemRow.tsx +27 -2
- package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +1 -0
- package/corpus/templates/content/app/components/editor/extensions/SuggestionHighlight.ts +383 -34
- package/corpus/templates/content/app/components/editor/suggestions/draft-session.ts +31 -0
- package/corpus/templates/content/app/components/editor/suggestions/presentation-rebase.ts +82 -4
- package/corpus/templates/content/app/global.css +31 -0
- package/corpus/templates/content/app/hooks/use-content-database.ts +40 -2
- package/corpus/templates/content/app/hooks/use-documents.ts +14 -0
- package/corpus/templates/content/app/i18n-data.ts +1 -0
- package/corpus/templates/content/changelog/2026-10-16-accepting-a-suggestion-on-a-page-that-changed-since-it-was-s.md +6 -0
- package/corpus/templates/content/changelog/2026-10-17-builder-content-avoids-retrying-a-write-when-builder-accepte.md +6 -0
- package/corpus/templates/content/changelog/2026-10-18-link-a-new-collection-to-a-github-folder-from-its-sources.md +1 -1
- package/corpus/templates/content/changelog/2026-10-20-builder-content-sync-keeps-read-and-write-status-accurate-wh.md +6 -0
- package/corpus/templates/content/changelog/2026-10-20-suggested-edits-that-add-remove-split-or-merge-paragraphs-li.md +6 -0
- package/corpus/templates/content/changelog/2026-10-21-list-rows-can-show-each-pages-first-lines.md +6 -0
- package/corpus/templates/content/changelog/2026-10-22-a-collection-linked-to-a-big-github-folder-fills-faster-and-p.md +6 -0
- package/corpus/templates/content/docs/product/capabilities/content.source.adapters.md +29 -0
- package/corpus/templates/content/parity/matrix.md +42 -42
- package/corpus/templates/content/parity/matrix.ts +3 -1
- package/corpus/templates/content/server/db/schema.ts +20 -0
- package/corpus/templates/content/server/lib/content-trash-purge.ts +15 -1
- package/corpus/templates/content/server/lib/live-body-writer.ts +138 -26
- package/corpus/templates/content/server/live-copy/binding.ts +31 -0
- package/corpus/templates/content/server/live-copy/bounded-task.d.ts +6 -0
- package/corpus/templates/content/server/live-copy/exec-db.ts +1 -0
- package/corpus/templates/content/server/live-copy/holds.ts +59 -0
- package/corpus/templates/content/server/live-copy/persist.ts +656 -0
- package/corpus/templates/content/server/live-copy/switch.ts +136 -0
- package/corpus/templates/content/server/live-copy/task-names.ts +4 -0
- package/corpus/templates/content/server/live-copy/tasks.ts +183 -0
- package/corpus/templates/content/server/live-copy/write-cause.ts +85 -0
- package/corpus/templates/content/server/live-copy/write.ts +85 -0
- package/corpus/templates/content/server/plugins/builder-private-payload-cleanup.ts +22 -0
- package/corpus/templates/content/server/plugins/collab.ts +3 -0
- package/corpus/templates/content/server/plugins/db.ts +28 -0
- package/corpus/templates/content/shared/api.ts +30 -1
- package/corpus/templates/content/shared/database-item-layout.ts +54 -22
- package/corpus/templates/content/shared/document-intent-merge.ts +16 -9
- package/corpus/templates/content/shared/og-description.ts +258 -36
- package/corpus/templates/content/shared/suggestion-diff.ts +23 -6
- package/corpus/templates/content/shared/suggestion-formatting.ts +44 -14
- package/corpus/templates/content/shared/suggestion-text.ts +20 -5
- package/corpus/templates/design/.agents/skills/design-templates/SKILL.md +5 -1
- package/corpus/templates/design/.generated/bridge/shader-runtime.generated.ts +25 -4
- package/corpus/templates/design/CHANGELOG.md +5 -0
- package/corpus/templates/design/actions/get-getting-started.ts +53 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +10 -0
- package/corpus/templates/design/app/components/design/bridge/native-shader-runtime.bridge.ts +132 -79
- package/corpus/templates/design/app/components/design/bridge/native-source-provider.ts +268 -130
- package/corpus/templates/design/app/components/design/bridge/native-worker-scene-host.ts +122 -42
- package/corpus/templates/design/app/components/design/bridge/shader-runtime.bridge.ts +41 -4
- package/corpus/templates/design/app/components/design/design-canvas/text-content-change-result.ts +46 -12
- package/corpus/templates/design/app/components/home/DesignHomeLibrary.tsx +15 -8
- package/corpus/templates/design/app/components/layout/GettingStarted.tsx +50 -0
- package/corpus/templates/design/app/components/layout/Sidebar.tsx +3 -0
- package/corpus/templates/design/app/components/templates/DesignTemplateGrid.tsx +163 -0
- package/corpus/templates/design/app/components/templates/DesignTemplateLibrary.tsx +65 -78
- package/corpus/templates/design/app/components/templates/TemplatePreview.tsx +6 -2
- package/corpus/templates/design/app/global.css +6 -0
- package/corpus/templates/design/app/i18n/en-US.ts +17 -0
- package/corpus/templates/design/app/lib/canvas-load-outcome.ts +147 -0
- package/corpus/templates/design/app/lib/canvas-outcome-tracking.ts +531 -0
- package/corpus/templates/design/app/lib/canvas-style-kind.ts +50 -0
- package/corpus/templates/design/app/pages/DesignSystems.tsx +1 -1
- package/corpus/templates/design/app/pages/Index.tsx +28 -67
- package/corpus/templates/design/app/pages/design-editor/commands/commit-visual-styles.ts +86 -29
- package/corpus/templates/design/app/pages/design-editor/commands/save-file-content.ts +38 -3
- package/corpus/templates/design/app/pages/design-editor/domains/use-editor-core.ts +9 -0
- package/corpus/templates/design/app/pages/design-editor/domains/use-editor-files-and-saving.ts +2 -0
- package/corpus/templates/design/changelog/2026-10-16-design-pages-use-localized-visibility-badges-and-a-shared-ow.md +6 -0
- package/corpus/templates/design/changelog/2026-10-21-a-getting-started-checklist-at-the-bottom-of-the-sidebar-tra.md +6 -0
- package/corpus/templates/design/changelog/2026-10-21-templates-now-open-a-read-only-preview-from-the-card-and-the.md +6 -0
- package/corpus/templates/design/changelog/2026-10-22-collapsed-shader-layers-no-longer-interrupt-other-shaders-on.md +6 -0
- package/corpus/templates/design/changelog/2026-10-22-shader-fills-keep-rendering-when-other-screen-content-cannot.md +6 -0
- package/corpus/templates/design/scripts/design-export-validation/export-baseline.json +24 -0
- package/corpus/templates/design/server/lib/getting-started.ts +267 -0
- package/corpus/templates/design/server/plugins/getting-started.ts +5 -0
- package/corpus/templates/design/shared/design-template-presets/analytics-dashboard.ts +127 -0
- package/corpus/templates/design/shared/design-template-presets/design-studio.ts +98 -0
- package/corpus/templates/design/shared/design-template-presets/messaging-app.ts +101 -0
- package/corpus/templates/design/shared/design-template-presets/project-tasks.ts +120 -0
- package/corpus/templates/design/shared/design-template-presets.ts +8 -0
- package/corpus/templates/dispatch/CHANGELOG.md +4 -0
- package/corpus/templates/dispatch/app/i18n-data.ts +75 -0
- package/corpus/templates/dispatch/changelog/2026-10-21-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
- package/corpus/templates/factory/app/components/layout/Sidebar.tsx +34 -100
- package/corpus/templates/forms/app/pages/FormsListPage.tsx +1 -1
- package/corpus/templates/forms/server/plugins/agent-chat.ts +1 -1
- package/corpus/templates/plan/CHANGELOG.md +6 -0
- package/corpus/templates/plan/app/components/layout/Sidebar.tsx +34 -109
- package/corpus/templates/plan/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
- package/corpus/templates/slides/CHANGELOG.md +7 -0
- package/corpus/templates/slides/actions/delete-deck.ts +7 -0
- package/corpus/templates/slides/app/components/deck/DeckCard.tsx +1 -1
- package/corpus/templates/slides/app/components/design-system/DesignSystemCard.tsx +1 -1
- package/corpus/templates/slides/app/pages/Index.tsx +7 -2
- package/corpus/templates/slides/changelog/2026-10-16-deck-lists-use-the-shared-ownership-filter-and-visibility-ba.md +6 -0
- package/corpus/templates/slides/changelog/2026-10-21-connected-deck-deletion-requires-approval.md +6 -0
- package/corpus/templates/slides/scripts/edit-fidelity/client-action.ts +61 -0
- package/corpus/templates/slides/scripts/edit-fidelity/run.ts +10 -0
- package/package.json +1 -1
- package/corpus/templates/chat/app/lib/sidebar-thread-state.ts +0 -29
- package/corpus/templates/slides/app/components/deck/DeckFilterMenu.tsx +0 -83
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
import { accessFilter } from "@agent-native/core/sharing";
|
|
2
|
+
import { type AnyColumn, and, eq, inArray, lt, lte, sql } from "drizzle-orm";
|
|
3
|
+
import { alias } from "drizzle-orm/pg-core";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
MAX_OUTPUTS_PER_RECORDING,
|
|
7
|
+
OUTPUT_USE_APPS,
|
|
8
|
+
OUTPUT_USE_EVENT_NAMES,
|
|
9
|
+
OUTPUT_USE_WINDOW_DAYS,
|
|
10
|
+
OUTPUT_USE_WINDOW_MS,
|
|
11
|
+
type OutputUseApp,
|
|
12
|
+
isOutputUseApp,
|
|
13
|
+
outputIdOf,
|
|
14
|
+
outputUrl,
|
|
15
|
+
outputUseRule,
|
|
16
|
+
type SessionOutput,
|
|
17
|
+
type SessionRecordingOutputs,
|
|
18
|
+
} from "../../shared/output-use.js";
|
|
19
|
+
import { getDb, schema } from "../db/index.js";
|
|
20
|
+
import { parseAllowedReplayOrigins } from "./replay-origins.js";
|
|
21
|
+
import {
|
|
22
|
+
recordingTenantSql,
|
|
23
|
+
type SessionEventIndexInputRow,
|
|
24
|
+
type SessionEventScope,
|
|
25
|
+
sessionEventTenantKey,
|
|
26
|
+
sessionIdOf,
|
|
27
|
+
stableId,
|
|
28
|
+
viewerReadsRecordingEventsSql,
|
|
29
|
+
viewerTenantKeys,
|
|
30
|
+
warnIndexFailure,
|
|
31
|
+
} from "./session-event-index.js";
|
|
32
|
+
import {
|
|
33
|
+
listRecordingSessionAssociations,
|
|
34
|
+
sessionRecordingAssociationsReady,
|
|
35
|
+
} from "./session-recording-associations.js";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Output outcomes: the outputs each tenant's apps made, as defined in
|
|
39
|
+
* `shared/output-use.ts`, and when each was first used. Ingest derives them
|
|
40
|
+
* inside the transaction that stores the events, whatever the sink, so no
|
|
41
|
+
* view reads raw events or BigQuery. A write that fails rolls back to a
|
|
42
|
+
* savepoint, and a batch ingest fails to store is lost; either records the
|
|
43
|
+
* event-time span the batch covered. An output
|
|
44
|
+
* whose use window overlaps such a span is unmeasured, never unused: the
|
|
45
|
+
* lost batch could have held its use.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
const OUTPUT_OUTCOME_RETENTION_BUFFER_DAYS = 2;
|
|
49
|
+
const SESSION_ID_BATCH_SIZE = 1_000;
|
|
50
|
+
const DAY_MS = 24 * 60 * 60_000;
|
|
51
|
+
|
|
52
|
+
let outputOutcomeTablesReady = false;
|
|
53
|
+
|
|
54
|
+
type OutputOutcomeRow = typeof schema.analyticsOutputOutcomes.$inferInsert;
|
|
55
|
+
type OutputOutcomeGapRow =
|
|
56
|
+
typeof schema.analyticsOutputOutcomeGaps.$inferInsert;
|
|
57
|
+
|
|
58
|
+
interface OutputTenant {
|
|
59
|
+
tenantKey: string;
|
|
60
|
+
ownerEmail: string;
|
|
61
|
+
orgId: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function coverageTableExists(db: any): Promise<boolean> {
|
|
65
|
+
const result = await db.execute(
|
|
66
|
+
sql`SELECT to_regclass('analytics_output_outcome_coverage') AS table_name`,
|
|
67
|
+
);
|
|
68
|
+
const rows = Array.isArray(result) ? result : result?.rows;
|
|
69
|
+
if (!Array.isArray(rows)) {
|
|
70
|
+
throw new Error("Postgres table existence check returned no row array");
|
|
71
|
+
}
|
|
72
|
+
const value = rows[0]?.table_name;
|
|
73
|
+
if (value === null) return false;
|
|
74
|
+
if (typeof value === "string" && value) return true;
|
|
75
|
+
throw new Error("Postgres table existence check returned an invalid value");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Code deploys before the scheduled migration creates these tables. Until
|
|
80
|
+
* the coverage table (created last) exists nothing is recorded, and reads
|
|
81
|
+
* say so. Tables are only ever added, so only "ready" is cached.
|
|
82
|
+
*/
|
|
83
|
+
async function outputOutcomesReady(db: any): Promise<boolean> {
|
|
84
|
+
outputOutcomeTablesReady ||= await coverageTableExists(db);
|
|
85
|
+
return outputOutcomeTablesReady;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Rows some rule reads, judged by name and app before any parsing. */
|
|
89
|
+
function outputRuleRows(
|
|
90
|
+
rows: readonly SessionEventIndexInputRow[],
|
|
91
|
+
): SessionEventIndexInputRow[] {
|
|
92
|
+
return rows.filter(
|
|
93
|
+
(row) =>
|
|
94
|
+
OUTPUT_USE_EVENT_NAMES.has(row.eventName) &&
|
|
95
|
+
isOutputUseApp(row.app) &&
|
|
96
|
+
Boolean(row.ownerEmail) &&
|
|
97
|
+
Boolean(row.timestamp),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Ingest serializes properties itself, so a parse failure is a real error. */
|
|
102
|
+
function parseProperties(properties: string): Record<string, unknown> {
|
|
103
|
+
const parsed: unknown = JSON.parse(properties);
|
|
104
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
105
|
+
? (parsed as Record<string, unknown>)
|
|
106
|
+
: {};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function computeUseBy(madeAt: string): string {
|
|
110
|
+
return new Date(Date.parse(madeAt) + OUTPUT_USE_WINDOW_MS).toISOString();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sortedById<T extends { id: string }>(rows: Iterable<T>): T[] {
|
|
114
|
+
return [...rows].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One batch's outcomes, merged the way the upsert merges them with stored
|
|
119
|
+
* rows: the earliest making and its session, and the earliest qualifying use.
|
|
120
|
+
* Every tenant that sent a rule's event gets coverage, even when nothing in
|
|
121
|
+
* the batch qualified.
|
|
122
|
+
*/
|
|
123
|
+
export function aggregateOutputOutcomeRows(
|
|
124
|
+
rows: readonly SessionEventIndexInputRow[],
|
|
125
|
+
): { outcomes: OutputOutcomeRow[]; tenants: OutputTenant[] } {
|
|
126
|
+
const outcomes = new Map<string, OutputOutcomeRow>();
|
|
127
|
+
const tenants = new Map<string, OutputTenant>();
|
|
128
|
+
for (const row of outputRuleRows(rows)) {
|
|
129
|
+
const app = row.app as OutputUseApp;
|
|
130
|
+
const rule = outputUseRule(app)!;
|
|
131
|
+
const made = rule.madeEvents.includes(row.eventName);
|
|
132
|
+
const used = rule.usedEvents.find(
|
|
133
|
+
(event) => event.eventName === row.eventName,
|
|
134
|
+
);
|
|
135
|
+
const orgId = row.orgId || null;
|
|
136
|
+
const tenantKey = sessionEventTenantKey(row.ownerEmail, orgId);
|
|
137
|
+
tenants.set(tenantKey, {
|
|
138
|
+
tenantKey,
|
|
139
|
+
ownerEmail: row.ownerEmail,
|
|
140
|
+
orgId,
|
|
141
|
+
});
|
|
142
|
+
const properties = parseProperties(row.properties);
|
|
143
|
+
const outputId = outputIdOf(properties);
|
|
144
|
+
if (!outputId) continue;
|
|
145
|
+
if (!made && !used?.qualifies(properties)) continue;
|
|
146
|
+
|
|
147
|
+
const id = stableId("aoo", [tenantKey, app, outputId]);
|
|
148
|
+
let outcome = outcomes.get(id);
|
|
149
|
+
if (!outcome) {
|
|
150
|
+
outcome = {
|
|
151
|
+
id,
|
|
152
|
+
tenantKey,
|
|
153
|
+
ownerEmail: row.ownerEmail,
|
|
154
|
+
orgId,
|
|
155
|
+
app,
|
|
156
|
+
outputType: rule.outputType,
|
|
157
|
+
outputId,
|
|
158
|
+
madeAt: null,
|
|
159
|
+
useBy: null,
|
|
160
|
+
madeSessionId: null,
|
|
161
|
+
firstUsedAt: null,
|
|
162
|
+
usedEvent: null,
|
|
163
|
+
lastSeenAt: row.timestamp,
|
|
164
|
+
};
|
|
165
|
+
outcomes.set(id, outcome);
|
|
166
|
+
}
|
|
167
|
+
if (row.timestamp > outcome.lastSeenAt) outcome.lastSeenAt = row.timestamp;
|
|
168
|
+
const sessionId = sessionIdOf(row.sessionId);
|
|
169
|
+
if (made) {
|
|
170
|
+
if (!outcome.madeAt || row.timestamp < outcome.madeAt) {
|
|
171
|
+
outcome.madeAt = row.timestamp;
|
|
172
|
+
outcome.useBy = computeUseBy(row.timestamp);
|
|
173
|
+
outcome.madeSessionId = sessionId ?? outcome.madeSessionId ?? null;
|
|
174
|
+
} else if (!outcome.madeSessionId) {
|
|
175
|
+
outcome.madeSessionId = sessionId;
|
|
176
|
+
}
|
|
177
|
+
} else if (!outcome.firstUsedAt || row.timestamp < outcome.firstUsedAt) {
|
|
178
|
+
outcome.firstUsedAt = row.timestamp;
|
|
179
|
+
outcome.usedEvent = row.eventName;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Sorted by id so concurrent batches lock shared rows in the same order.
|
|
183
|
+
return {
|
|
184
|
+
outcomes: sortedById(outcomes.values()),
|
|
185
|
+
tenants: [...tenants.values()].sort((a, b) =>
|
|
186
|
+
a.tenantKey < b.tenantKey ? -1 : a.tenantKey > b.tenantKey ? 1 : 0,
|
|
187
|
+
),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** One marker per tenant spanning the event times its failed batch held. */
|
|
192
|
+
function outputGapRows(
|
|
193
|
+
rows: readonly SessionEventIndexInputRow[],
|
|
194
|
+
recordedAt: string,
|
|
195
|
+
): OutputOutcomeGapRow[] {
|
|
196
|
+
const spans = new Map<string, OutputOutcomeGapRow>();
|
|
197
|
+
for (const row of rows) {
|
|
198
|
+
const orgId = row.orgId || null;
|
|
199
|
+
const tenantKey = sessionEventTenantKey(row.ownerEmail, orgId);
|
|
200
|
+
const span = spans.get(tenantKey);
|
|
201
|
+
if (!span) {
|
|
202
|
+
spans.set(tenantKey, {
|
|
203
|
+
id: "",
|
|
204
|
+
tenantKey,
|
|
205
|
+
ownerEmail: row.ownerEmail,
|
|
206
|
+
orgId,
|
|
207
|
+
firstAt: row.timestamp,
|
|
208
|
+
lastAt: row.timestamp,
|
|
209
|
+
recordedAt,
|
|
210
|
+
});
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (row.timestamp < span.firstAt) span.firstAt = row.timestamp;
|
|
214
|
+
if (row.timestamp > span.lastAt) span.lastAt = row.timestamp;
|
|
215
|
+
}
|
|
216
|
+
return sortedById(
|
|
217
|
+
[...spans.values()].map((span) => ({
|
|
218
|
+
...span,
|
|
219
|
+
id: stableId("aoog", [span.tenantKey, span.firstAt, span.lastAt]),
|
|
220
|
+
})),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Runs inside the transaction that stores the events. A failure rolls back
|
|
226
|
+
* to a savepoint and never fails ingest; only a failed gap marker does,
|
|
227
|
+
* taking the events with it, so no output silently loses a use. The
|
|
228
|
+
* readiness probe belongs inside the savepoint: a failed query aborts the
|
|
229
|
+
* transaction it runs in.
|
|
230
|
+
*/
|
|
231
|
+
export async function recordOutputOutcomes(
|
|
232
|
+
tx: any,
|
|
233
|
+
rows: readonly SessionEventIndexInputRow[],
|
|
234
|
+
receivedAt: string,
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
const ruleRows = outputRuleRows(rows);
|
|
237
|
+
if (!ruleRows.length) return;
|
|
238
|
+
try {
|
|
239
|
+
await tx.transaction(async (savepoint: any) => {
|
|
240
|
+
if (!(await outputOutcomesReady(savepoint))) return;
|
|
241
|
+
const { outcomes, tenants } = aggregateOutputOutcomeRows(ruleRows);
|
|
242
|
+
if (outcomes.length) {
|
|
243
|
+
const o = schema.analyticsOutputOutcomes;
|
|
244
|
+
const earlierMade = sql`excluded.made_at is not null and (${o.madeAt} is null or excluded.made_at < ${o.madeAt})`;
|
|
245
|
+
const earlierUse = sql`excluded.first_used_at is not null and (${o.firstUsedAt} is null or excluded.first_used_at < ${o.firstUsedAt})`;
|
|
246
|
+
await savepoint
|
|
247
|
+
.insert(o)
|
|
248
|
+
.values(outcomes)
|
|
249
|
+
.onConflictDoUpdate({
|
|
250
|
+
target: [o.tenantKey, o.app, o.outputId],
|
|
251
|
+
set: {
|
|
252
|
+
// least() and greatest() skip nulls in Postgres.
|
|
253
|
+
madeAt: sql`least(${o.madeAt}, excluded.made_at)`,
|
|
254
|
+
useBy: sql`least(${o.useBy}, excluded.use_by)`,
|
|
255
|
+
madeSessionId: sql`case when ${earlierMade} then coalesce(excluded.made_session_id, ${o.madeSessionId}) else coalesce(${o.madeSessionId}, excluded.made_session_id) end`,
|
|
256
|
+
firstUsedAt: sql`least(${o.firstUsedAt}, excluded.first_used_at)`,
|
|
257
|
+
usedEvent: sql`case when ${earlierUse} then excluded.used_event else ${o.usedEvent} end`,
|
|
258
|
+
lastSeenAt: sql`greatest(${o.lastSeenAt}, excluded.last_seen_at)`,
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
await savepoint
|
|
263
|
+
.insert(schema.analyticsOutputOutcomeCoverage)
|
|
264
|
+
.values(tenants.map((tenant) => ({ ...tenant, startedAt: receivedAt })))
|
|
265
|
+
.onConflictDoNothing();
|
|
266
|
+
});
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (!(await coverageTableExists(tx))) {
|
|
269
|
+
warnIndexFailure(
|
|
270
|
+
"Output outcome tables are not migrated yet; events were stored without output outcomes:",
|
|
271
|
+
error,
|
|
272
|
+
);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
await tx
|
|
276
|
+
.insert(schema.analyticsOutputOutcomeGaps)
|
|
277
|
+
.values(outputGapRows(ruleRows, receivedAt))
|
|
278
|
+
.onConflictDoNothing();
|
|
279
|
+
warnIndexFailure(
|
|
280
|
+
"Output outcome write failed; outputs in its span read as unmeasured:",
|
|
281
|
+
error,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Ingest rejected or failed to store a batch, and the tracker does not resend
|
|
288
|
+
* it, so any use it held is gone. Records its span outside the rolled-back
|
|
289
|
+
* transaction so outputs it could have used read as unmeasured, not unused.
|
|
290
|
+
*/
|
|
291
|
+
export async function recordLostOutputBatch(
|
|
292
|
+
db: any,
|
|
293
|
+
rows: readonly SessionEventIndexInputRow[],
|
|
294
|
+
receivedAt: string,
|
|
295
|
+
): Promise<void> {
|
|
296
|
+
const ruleRows = outputRuleRows(rows);
|
|
297
|
+
if (!ruleRows.length) return;
|
|
298
|
+
try {
|
|
299
|
+
if (!(await outputOutcomesReady(db))) return;
|
|
300
|
+
await db
|
|
301
|
+
.insert(schema.analyticsOutputOutcomeGaps)
|
|
302
|
+
.values(outputGapRows(ruleRows, receivedAt))
|
|
303
|
+
.onConflictDoNothing();
|
|
304
|
+
} catch (error) {
|
|
305
|
+
console.error(
|
|
306
|
+
"[first-party-analytics] A failed batch's output span was not recorded; a use it held may read as unused:",
|
|
307
|
+
error,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** A gap marker whose span overlaps the use window of the output in `o`. */
|
|
313
|
+
function gapOverlapsOutputSql(o: typeof schema.analyticsOutputOutcomes) {
|
|
314
|
+
const gaps = schema.analyticsOutputOutcomeGaps;
|
|
315
|
+
return sql`${gaps.tenantKey} = ${o.tenantKey}
|
|
316
|
+
and ${gaps.firstAt} <= ${o.useBy}
|
|
317
|
+
and ${gaps.lastAt} >= ${o.madeAt}`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* A gap marker whose span overlaps the recording in `r`. The lost batch may
|
|
322
|
+
* have held an output that recording's session made, and no row records it.
|
|
323
|
+
*/
|
|
324
|
+
function gapOverlapsRecordingSql(r: typeof schema.sessionRecordings) {
|
|
325
|
+
const gaps = schema.analyticsOutputOutcomeGaps;
|
|
326
|
+
return sql`${gaps.tenantKey} = ${recordingTenantSql(r)}
|
|
327
|
+
and ${gaps.firstAt} <= coalesce(${r.endedAt}, ${r.lastIngestedAt}, ${r.startedAt})
|
|
328
|
+
and ${gaps.lastAt} >= ${r.startedAt}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** An output stored in `o` that was made but not used once its window closed. */
|
|
332
|
+
function unusedOutputSql(
|
|
333
|
+
o: typeof schema.analyticsOutputOutcomes,
|
|
334
|
+
nowIso: string,
|
|
335
|
+
) {
|
|
336
|
+
const coverage = schema.analyticsOutputOutcomeCoverage;
|
|
337
|
+
const gaps = schema.analyticsOutputOutcomeGaps;
|
|
338
|
+
return sql`${o.madeAt} is not null
|
|
339
|
+
and ${o.useBy} <= ${nowIso}
|
|
340
|
+
and ${o.madeAt} >= (select ${coverage.startedAt} from ${coverage} where ${coverage.tenantKey} = ${o.tenantKey})
|
|
341
|
+
and (${o.firstUsedAt} is null or ${o.firstUsedAt} > ${o.useBy})
|
|
342
|
+
and not exists (select 1 from ${gaps} where ${gapOverlapsOutputSql(o)})`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Conditions on `session_recordings` for "made it, didn't use it": the
|
|
347
|
+
* recording's session made an output of `app` whose use window has closed
|
|
348
|
+
* with no qualifying use. Each lookup is correlated to the recording's own
|
|
349
|
+
* tenant and sessions, so it never widens the recording access filter it is
|
|
350
|
+
* combined with, and a recording shared from another tenant never matches.
|
|
351
|
+
* Nor does one a lost batch overlapped, whose outputs read as unknown.
|
|
352
|
+
*/
|
|
353
|
+
export async function unusedOutputConditions(
|
|
354
|
+
scope: SessionEventScope,
|
|
355
|
+
app: OutputUseApp | undefined,
|
|
356
|
+
now = new Date(),
|
|
357
|
+
) {
|
|
358
|
+
if (!app) return [];
|
|
359
|
+
const db = getDb() as any;
|
|
360
|
+
if (!(await outputOutcomesReady(db))) return [sql`false`];
|
|
361
|
+
const associationsReady = await sessionRecordingAssociationsReady(db);
|
|
362
|
+
const r = schema.sessionRecordings;
|
|
363
|
+
const o = schema.analyticsOutputOutcomes;
|
|
364
|
+
const association = alias(
|
|
365
|
+
schema.sessionRecordingSessionAssociations,
|
|
366
|
+
"unused_output_association",
|
|
367
|
+
);
|
|
368
|
+
const associationPresence = alias(
|
|
369
|
+
schema.sessionRecordingSessionAssociations,
|
|
370
|
+
"unused_output_association_presence",
|
|
371
|
+
);
|
|
372
|
+
const tenant = recordingTenantSql(r);
|
|
373
|
+
const unused = unusedOutputSql(o, now.toISOString());
|
|
374
|
+
const madeUnusedIn = (sessionId: AnyColumn) => sql`exists (
|
|
375
|
+
select 1 from ${o}
|
|
376
|
+
where ${o.tenantKey} = ${tenant}
|
|
377
|
+
and ${o.app} = ${app}
|
|
378
|
+
and ${o.madeSessionId} = ${sessionId}
|
|
379
|
+
and ${unused}
|
|
380
|
+
)`;
|
|
381
|
+
const match = associationsReady
|
|
382
|
+
? sql`(
|
|
383
|
+
exists (
|
|
384
|
+
select 1 from ${schema.sessionRecordingSessionAssociations} as ${association}
|
|
385
|
+
where ${association.recordingId} = ${r.id}
|
|
386
|
+
and ${madeUnusedIn(association.sessionId)}
|
|
387
|
+
)
|
|
388
|
+
or (
|
|
389
|
+
not exists (
|
|
390
|
+
select 1 from ${schema.sessionRecordingSessionAssociations} as ${associationPresence}
|
|
391
|
+
where ${associationPresence.recordingId} = ${r.id}
|
|
392
|
+
)
|
|
393
|
+
and ${madeUnusedIn(r.sessionId)}
|
|
394
|
+
)
|
|
395
|
+
)`
|
|
396
|
+
: madeUnusedIn(r.sessionId);
|
|
397
|
+
return [
|
|
398
|
+
viewerReadsRecordingEventsSql(r, scope),
|
|
399
|
+
match,
|
|
400
|
+
sql`not exists (select 1 from ${schema.analyticsOutputOutcomeGaps} where ${gapOverlapsRecordingSql(r)})`,
|
|
401
|
+
];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Each of the viewer's tenants that has started output tracking, with its start. */
|
|
405
|
+
async function viewerOutputCoverage(
|
|
406
|
+
db: any,
|
|
407
|
+
scope: SessionEventScope,
|
|
408
|
+
): Promise<Map<string, string>> {
|
|
409
|
+
if (!(await outputOutcomesReady(db))) return new Map();
|
|
410
|
+
const coverage = schema.analyticsOutputOutcomeCoverage;
|
|
411
|
+
const rows: Array<{ tenantKey: string; startedAt: string | null }> = await db
|
|
412
|
+
.select({ tenantKey: coverage.tenantKey, startedAt: coverage.startedAt })
|
|
413
|
+
.from(coverage)
|
|
414
|
+
.where(inArray(coverage.tenantKey, viewerTenantKeys(scope)));
|
|
415
|
+
return new Map(
|
|
416
|
+
rows.flatMap((row) =>
|
|
417
|
+
row.startedAt ? [[row.tenantKey, row.startedAt] as const] : [],
|
|
418
|
+
),
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
interface StoredOutput {
|
|
423
|
+
tenantKey: string;
|
|
424
|
+
app: string;
|
|
425
|
+
outputType: string;
|
|
426
|
+
outputId: string;
|
|
427
|
+
madeAt: string;
|
|
428
|
+
useBy: string;
|
|
429
|
+
madeSessionId: string;
|
|
430
|
+
firstUsedAt: string | null;
|
|
431
|
+
usedEvent: string | null;
|
|
432
|
+
hasGap: boolean;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function outputStatus(
|
|
436
|
+
output: StoredOutput,
|
|
437
|
+
nowIso: string,
|
|
438
|
+
): SessionOutput["status"] {
|
|
439
|
+
if (output.firstUsedAt && output.firstUsedAt <= output.useBy) return "used";
|
|
440
|
+
if (output.useBy > nowIso) return "open";
|
|
441
|
+
return output.hasGap ? "unmeasured" : "unused";
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The outputs each recording's sessions made, for recordings the viewer can
|
|
446
|
+
* read, such as one list page's rows. A share grants the recording, not its
|
|
447
|
+
* tenant's events, so a recording shared from another tenant reads as null,
|
|
448
|
+
* as do one whose tenant has not started output tracking and one a lost batch
|
|
449
|
+
* overlapped: nothing says whether any of them made anything.
|
|
450
|
+
* Each recording lists at most `MAX_OUTPUTS_PER_RECORDING`, unused ones
|
|
451
|
+
* first, so a row the unused filter matched always lists the output it
|
|
452
|
+
* matched on; the rest are counted in `omittedOutputs`.
|
|
453
|
+
*/
|
|
454
|
+
export async function getSessionRecordingOutputs(
|
|
455
|
+
scope: SessionEventScope,
|
|
456
|
+
recordingIds: readonly string[],
|
|
457
|
+
now = new Date(),
|
|
458
|
+
): Promise<SessionRecordingOutputs> {
|
|
459
|
+
const ids = [...new Set(recordingIds)];
|
|
460
|
+
const db = getDb() as any;
|
|
461
|
+
const coveredTenants = await viewerOutputCoverage(db, scope);
|
|
462
|
+
// Each output is judged against its own tenant's start, so only the latest
|
|
463
|
+
// holds for all.
|
|
464
|
+
const coverageStartedAt = [...coveredTenants.values()].sort().pop() ?? null;
|
|
465
|
+
const outputs: Record<string, SessionOutput[] | null> = {};
|
|
466
|
+
const omittedOutputs: Record<string, number> = {};
|
|
467
|
+
if (!ids.length) return { outputs, omittedOutputs, coverageStartedAt };
|
|
468
|
+
const r = schema.sessionRecordings;
|
|
469
|
+
const keys = schema.analyticsPublicKeys;
|
|
470
|
+
const readable: Array<{
|
|
471
|
+
id: string;
|
|
472
|
+
sessionId: string;
|
|
473
|
+
ownerEmail: string;
|
|
474
|
+
orgId: string | null;
|
|
475
|
+
firstUrl: string | null;
|
|
476
|
+
lastUrl: string | null;
|
|
477
|
+
allowedOrigins: string | null;
|
|
478
|
+
gapped?: boolean;
|
|
479
|
+
}> = await db
|
|
480
|
+
.select({
|
|
481
|
+
id: r.id,
|
|
482
|
+
sessionId: r.sessionId,
|
|
483
|
+
ownerEmail: r.ownerEmail,
|
|
484
|
+
orgId: r.orgId,
|
|
485
|
+
firstUrl: r.firstUrl,
|
|
486
|
+
lastUrl: r.lastUrl,
|
|
487
|
+
allowedOrigins: sql<string | null>`(
|
|
488
|
+
select ${keys.replayAllowedOrigins} from ${keys}
|
|
489
|
+
where ${keys.id} = ${r.publicKeyId}
|
|
490
|
+
)`,
|
|
491
|
+
// Coverage is created after the gap table, so it is safe to read here.
|
|
492
|
+
...(coveredTenants.size
|
|
493
|
+
? {
|
|
494
|
+
gapped: sql<boolean>`exists (
|
|
495
|
+
select 1 from ${schema.analyticsOutputOutcomeGaps}
|
|
496
|
+
where ${gapOverlapsRecordingSql(r)}
|
|
497
|
+
)`,
|
|
498
|
+
}
|
|
499
|
+
: {}),
|
|
500
|
+
})
|
|
501
|
+
.from(r)
|
|
502
|
+
.where(
|
|
503
|
+
and(
|
|
504
|
+
accessFilter(r, schema.sessionRecordingShares, {
|
|
505
|
+
userEmail: scope.userEmail,
|
|
506
|
+
orgId: scope.orgId ?? undefined,
|
|
507
|
+
}),
|
|
508
|
+
inArray(r.id, ids),
|
|
509
|
+
),
|
|
510
|
+
)
|
|
511
|
+
.limit(ids.length);
|
|
512
|
+
const recordings: typeof readable = [];
|
|
513
|
+
for (const recording of readable) {
|
|
514
|
+
const known =
|
|
515
|
+
coveredTenants.has(
|
|
516
|
+
sessionEventTenantKey(recording.ownerEmail, recording.orgId),
|
|
517
|
+
) && !recording.gapped;
|
|
518
|
+
outputs[recording.id] = known ? [] : null;
|
|
519
|
+
if (known) recordings.push(recording);
|
|
520
|
+
}
|
|
521
|
+
if (!recordings.length) {
|
|
522
|
+
return { outputs, omittedOutputs, coverageStartedAt };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const sessionIdsByRecording = new Map<string, string[]>();
|
|
526
|
+
if (await sessionRecordingAssociationsReady(db)) {
|
|
527
|
+
for (const row of await listRecordingSessionAssociations(
|
|
528
|
+
db,
|
|
529
|
+
recordings.map((recording) => recording.id),
|
|
530
|
+
)) {
|
|
531
|
+
const values = sessionIdsByRecording.get(row.recordingId) ?? [];
|
|
532
|
+
values.push(row.sessionId);
|
|
533
|
+
sessionIdsByRecording.set(row.recordingId, values);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
const sessionIdsByTenant = new Map<string, Set<string>>();
|
|
537
|
+
for (const recording of recordings) {
|
|
538
|
+
if (!sessionIdsByRecording.has(recording.id)) {
|
|
539
|
+
sessionIdsByRecording.set(recording.id, [recording.sessionId]);
|
|
540
|
+
}
|
|
541
|
+
const tenantKey = sessionEventTenantKey(
|
|
542
|
+
recording.ownerEmail,
|
|
543
|
+
recording.orgId,
|
|
544
|
+
);
|
|
545
|
+
const tenantSessionIds =
|
|
546
|
+
sessionIdsByTenant.get(tenantKey) ?? new Set<string>();
|
|
547
|
+
for (const sessionId of sessionIdsByRecording.get(recording.id)!) {
|
|
548
|
+
tenantSessionIds.add(sessionId);
|
|
549
|
+
}
|
|
550
|
+
sessionIdsByTenant.set(tenantKey, tenantSessionIds);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const nowIso = now.toISOString();
|
|
554
|
+
const o = schema.analyticsOutputOutcomes;
|
|
555
|
+
const coverage = schema.analyticsOutputOutcomeCoverage;
|
|
556
|
+
const gaps = schema.analyticsOutputOutcomeGaps;
|
|
557
|
+
const stored: StoredOutput[] = [];
|
|
558
|
+
const madeBySession = new Map<string, number>();
|
|
559
|
+
for (const [tenantKey, tenantSessionIds] of sessionIdsByTenant) {
|
|
560
|
+
const sessionIds = [...tenantSessionIds];
|
|
561
|
+
for (
|
|
562
|
+
let offset = 0;
|
|
563
|
+
offset < sessionIds.length;
|
|
564
|
+
offset += SESSION_ID_BATCH_SIZE
|
|
565
|
+
) {
|
|
566
|
+
const batch = sessionIds.slice(offset, offset + SESSION_ID_BATCH_SIZE);
|
|
567
|
+
// Each session's outputs are ranked unused first and cut in SQL, so a
|
|
568
|
+
// session that made thousands reads no more than a recording can list.
|
|
569
|
+
const ranked = db
|
|
570
|
+
.select({
|
|
571
|
+
tenantKey: o.tenantKey,
|
|
572
|
+
app: o.app,
|
|
573
|
+
outputType: o.outputType,
|
|
574
|
+
outputId: o.outputId,
|
|
575
|
+
madeAt: o.madeAt,
|
|
576
|
+
useBy: o.useBy,
|
|
577
|
+
madeSessionId: o.madeSessionId,
|
|
578
|
+
firstUsedAt: o.firstUsedAt,
|
|
579
|
+
usedEvent: o.usedEvent,
|
|
580
|
+
hasGap: sql<boolean>`exists (
|
|
581
|
+
select 1 from ${gaps} where ${gapOverlapsOutputSql(o)}
|
|
582
|
+
)`.as("has_gap"),
|
|
583
|
+
sessionRank: sql<number>`row_number() over (
|
|
584
|
+
partition by ${o.madeSessionId}
|
|
585
|
+
order by case when ${unusedOutputSql(o, nowIso)} then 0 else 1 end, ${o.madeAt}, ${o.outputId}
|
|
586
|
+
)`.as("session_rank"),
|
|
587
|
+
sessionMade:
|
|
588
|
+
sql<number>`count(*) over (partition by ${o.madeSessionId})`.as(
|
|
589
|
+
"session_made",
|
|
590
|
+
),
|
|
591
|
+
})
|
|
592
|
+
.from(o)
|
|
593
|
+
.where(
|
|
594
|
+
and(
|
|
595
|
+
eq(o.tenantKey, tenantKey),
|
|
596
|
+
inArray(o.madeSessionId, batch),
|
|
597
|
+
inArray(o.app, OUTPUT_USE_APPS),
|
|
598
|
+
sql`${o.madeAt} is not null`,
|
|
599
|
+
sql`${o.madeAt} >= (select ${coverage.startedAt} from ${coverage} where ${coverage.tenantKey} = ${o.tenantKey})`,
|
|
600
|
+
),
|
|
601
|
+
)
|
|
602
|
+
.as("ranked_outputs");
|
|
603
|
+
const rows = await db
|
|
604
|
+
.select()
|
|
605
|
+
.from(ranked)
|
|
606
|
+
.where(lte(ranked.sessionRank, MAX_OUTPUTS_PER_RECORDING));
|
|
607
|
+
for (const row of rows) {
|
|
608
|
+
stored.push(row);
|
|
609
|
+
madeBySession.set(
|
|
610
|
+
JSON.stringify([row.tenantKey, row.madeSessionId]),
|
|
611
|
+
Number(row.sessionMade),
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const outputsBySession = new Map<string, StoredOutput[]>();
|
|
618
|
+
for (const output of stored) {
|
|
619
|
+
const key = JSON.stringify([output.tenantKey, output.madeSessionId]);
|
|
620
|
+
const values = outputsBySession.get(key) ?? [];
|
|
621
|
+
values.push(output);
|
|
622
|
+
outputsBySession.set(key, values);
|
|
623
|
+
}
|
|
624
|
+
for (const recording of recordings) {
|
|
625
|
+
const tenantKey = sessionEventTenantKey(
|
|
626
|
+
recording.ownerEmail,
|
|
627
|
+
recording.orgId,
|
|
628
|
+
);
|
|
629
|
+
// An output has one making session, so distinct sessions never list the
|
|
630
|
+
// same output twice.
|
|
631
|
+
const made: StoredOutput[] = [];
|
|
632
|
+
let madeCount = 0;
|
|
633
|
+
for (const sessionId of new Set(sessionIdsByRecording.get(recording.id))) {
|
|
634
|
+
const key = JSON.stringify([tenantKey, sessionId]);
|
|
635
|
+
made.push(...(outputsBySession.get(key) ?? []));
|
|
636
|
+
madeCount += madeBySession.get(key) ?? 0;
|
|
637
|
+
}
|
|
638
|
+
const listed = made
|
|
639
|
+
.map((output) => ({ output, status: outputStatus(output, nowIso) }))
|
|
640
|
+
.sort(
|
|
641
|
+
(a, b) =>
|
|
642
|
+
Number(b.status === "unused") - Number(a.status === "unused") ||
|
|
643
|
+
a.output.madeAt.localeCompare(b.output.madeAt) ||
|
|
644
|
+
a.output.outputId.localeCompare(b.output.outputId),
|
|
645
|
+
)
|
|
646
|
+
.slice(0, MAX_OUTPUTS_PER_RECORDING)
|
|
647
|
+
.sort(
|
|
648
|
+
(a, b) =>
|
|
649
|
+
a.output.madeAt.localeCompare(b.output.madeAt) ||
|
|
650
|
+
a.output.outputId.localeCompare(b.output.outputId),
|
|
651
|
+
);
|
|
652
|
+
if (madeCount > listed.length) {
|
|
653
|
+
omittedOutputs[recording.id] = madeCount - listed.length;
|
|
654
|
+
}
|
|
655
|
+
const pages = {
|
|
656
|
+
firstUrl: recording.firstUrl,
|
|
657
|
+
lastUrl: recording.lastUrl,
|
|
658
|
+
allowedOrigins: parseAllowedReplayOrigins(recording.allowedOrigins),
|
|
659
|
+
};
|
|
660
|
+
outputs[recording.id] = listed.map(({ output, status }) => ({
|
|
661
|
+
app: output.app as OutputUseApp,
|
|
662
|
+
outputType: output.outputType,
|
|
663
|
+
outputId: output.outputId,
|
|
664
|
+
madeAt: output.madeAt,
|
|
665
|
+
useBy: output.useBy,
|
|
666
|
+
status,
|
|
667
|
+
usedEvent: status === "used" ? output.usedEvent : null,
|
|
668
|
+
usedAt: status === "used" ? output.firstUsedAt : null,
|
|
669
|
+
url: outputUrl(output.app, output.outputId, pages),
|
|
670
|
+
}));
|
|
671
|
+
}
|
|
672
|
+
return { outputs, omittedOutputs, coverageStartedAt };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* An outcome outlives the replays its session recorded by the use window and
|
|
677
|
+
* a little more, so a retained recording never loses its outputs.
|
|
678
|
+
*/
|
|
679
|
+
export async function pruneOutputOutcomes(
|
|
680
|
+
replayRetentionDays: number,
|
|
681
|
+
now = new Date(),
|
|
682
|
+
): Promise<{ outputs: number }> {
|
|
683
|
+
const db = getDb() as any;
|
|
684
|
+
if (!(await outputOutcomesReady(db))) return { outputs: 0 };
|
|
685
|
+
const cutoff = new Date(
|
|
686
|
+
now.getTime() -
|
|
687
|
+
(replayRetentionDays +
|
|
688
|
+
OUTPUT_USE_WINDOW_DAYS +
|
|
689
|
+
OUTPUT_OUTCOME_RETENTION_BUFFER_DAYS) *
|
|
690
|
+
DAY_MS,
|
|
691
|
+
).toISOString();
|
|
692
|
+
// guard:allow-unscoped -- retention intentionally sweeps expired output outcomes across tenants.
|
|
693
|
+
const result = await db
|
|
694
|
+
.delete(schema.analyticsOutputOutcomes)
|
|
695
|
+
.where(lt(schema.analyticsOutputOutcomes.lastSeenAt, cutoff));
|
|
696
|
+
// A late event keeps an old output's row past the cutoff, so a gap is kept
|
|
697
|
+
// while any remaining output overlaps it; otherwise that output reads unused.
|
|
698
|
+
// guard:allow-unscoped -- retention intentionally sweeps expired gap markers across tenants.
|
|
699
|
+
await db
|
|
700
|
+
.delete(schema.analyticsOutputOutcomeGaps)
|
|
701
|
+
.where(
|
|
702
|
+
and(
|
|
703
|
+
lt(schema.analyticsOutputOutcomeGaps.lastAt, cutoff),
|
|
704
|
+
sql`not exists (select 1 from ${schema.analyticsOutputOutcomes} where ${gapOverlapsOutputSql(schema.analyticsOutputOutcomes)})`,
|
|
705
|
+
),
|
|
706
|
+
);
|
|
707
|
+
return { outputs: Number(result?.rowCount ?? 0) };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
export function __resetOutputOutcomesForTests(): void {
|
|
711
|
+
outputOutcomeTablesReady = false;
|
|
712
|
+
}
|