Merge branch 'analytics-replays-3' into analytics-replays-4

This commit is contained in:
BilalG1 2026-02-11 19:12:28 -08:00 committed by GitHub
commit c4d6503131
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 103 additions and 56 deletions

View File

@ -67,6 +67,7 @@ STACK_S3_REGION=
STACK_S3_ACCESS_KEY_ID=
STACK_S3_SECRET_ACCESS_KEY=
STACK_S3_BUCKET=
STACK_S3_PRIVATE_BUCKET=
# AWS configuration
STACK_AWS_REGION=

View File

@ -74,6 +74,7 @@ STACK_S3_REGION=us-east-1
STACK_S3_ACCESS_KEY_ID=s3mockroot
STACK_S3_SECRET_ACCESS_KEY=s3mockroot
STACK_S3_BUCKET=stack-storage
STACK_S3_PRIVATE_BUCKET=stack-storage-private
# AWS region defaults to LocalStack
STACK_AWS_REGION=us-east-1

View File

@ -43,6 +43,9 @@ ALTER TABLE "SessionRecordingChunk"
ADD CONSTRAINT "SessionRecordingChunk_sessionRecordingId_fkey"
FOREIGN KEY ("tenancyId","sessionRecordingId") REFERENCES "SessionRecording"("tenancyId","id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE UNIQUE INDEX "SessionRecording_tenancyId_refreshTokenId_key"
ON "SessionRecording"("tenancyId", "refreshTokenId");
CREATE INDEX "SessionRecording_tenancyId_projectUserId_startedAt_idx"
ON "SessionRecording"("tenancyId", "projectUserId", "startedAt");

View File

@ -300,6 +300,7 @@ model SessionRecording {
chunks SessionRecordingChunk[]
@@id([tenancyId, id])
@@unique([tenancyId, refreshTokenId])
@@index([tenancyId, projectUserId, startedAt])
@@index([tenancyId, lastEventAt])
}

View File

@ -102,13 +102,13 @@ export const POST = createSmartRouteHandler({
// Ensure the session row exists and is up-to-date.
const existingSession = await prisma.sessionRecording.findUnique({
where: { tenancyId_id: { tenancyId, id: sessionId } },
where: { tenancyId_refreshTokenId: { tenancyId, refreshTokenId } },
select: { startedAt: true, lastEventAt: true },
});
const newStartedAtMs = Math.min(existingSession?.startedAt.getTime() ?? Number.POSITIVE_INFINITY, firstMs);
const newLastEventAtMs = Math.max(existingSession?.lastEventAt.getTime() ?? 0, lastMs);
await prisma.sessionRecording.upsert({
where: { tenancyId_id: { tenancyId, id: sessionId } },
where: { tenancyId_refreshTokenId: { tenancyId, refreshTokenId } },
create: {
id: sessionId,
tenancyId,
@ -120,7 +120,6 @@ export const POST = createSmartRouteHandler({
lastEventAt: new Date(newLastEventAtMs),
},
update: {
refreshTokenId,
startedAt: new Date(newStartedAtMs),
lastEventAt: new Date(newLastEventAtMs),
},
@ -161,6 +160,7 @@ export const POST = createSmartRouteHandler({
body: gzipped,
contentType: "application/json",
contentEncoding: "gzip",
private: true,
});
try {

View File

@ -82,7 +82,6 @@ async function verifyEmailDeliverability(
}
const json = await emailableResponse.json() as Record<string, unknown>;
console.log("emailableResponse", json);
if (json.state === "undeliverable" || json.disposable) {
console.log("email not deliverable", email, json);

View File

@ -74,17 +74,25 @@ async function _lowLevelSendEmailWithoutRetries(options: LowLevelSendEmailOption
host: options.emailConfig.host,
port: options.emailConfig.port,
secure: options.emailConfig.secure,
connectionTimeout: 15000,
greetingTimeout: 10000,
socketTimeout: 20000,
dnsTimeout: 7000,
auth: {
user: options.emailConfig.username,
pass: options.emailConfig.password,
},
});
await transporter.sendMail({
from: `"${options.emailConfig.senderName}" <${options.emailConfig.senderEmail}>`,
...options,
to: toArray,
});
try {
await transporter.sendMail({
from: `"${options.emailConfig.senderName}" <${options.emailConfig.senderEmail}>`,
...options,
to: toArray,
});
} finally {
transporter.close();
}
return Result.ok(undefined);
} catch (error) {
@ -292,7 +300,7 @@ export async function lowLevelSendEmailDirectViaProvider(options: LowLevelSendEm
}
return result;
}, 3, { exponentialDelayBase: 2000 });
}, 5, { exponentialDelayBase: 1000 });
} catch (error) {
if (error instanceof DoNotRetryError) {
return Result.error(error.errorObj);

View File

@ -7,6 +7,7 @@ const S3_REGION = getEnvVariable("STACK_S3_REGION", "");
const S3_ENDPOINT = getEnvVariable("STACK_S3_ENDPOINT", "");
const S3_PUBLIC_ENDPOINT = getEnvVariable("STACK_S3_PUBLIC_ENDPOINT", "");
const S3_BUCKET = getEnvVariable("STACK_S3_BUCKET", "");
const S3_PRIVATE_BUCKET = getEnvVariable("STACK_S3_PRIVATE_BUCKET", "");
const S3_ACCESS_KEY_ID = getEnvVariable("STACK_S3_ACCESS_KEY_ID", "");
const S3_SECRET_ACCESS_KEY = getEnvVariable("STACK_S3_SECRET_ACCESS_KEY", "");
@ -16,6 +17,10 @@ if (!HAS_S3) {
console.warn("S3 bucket is not configured. File upload features will not be available.");
}
if (HAS_S3 && !S3_PRIVATE_BUCKET) {
console.warn("S3 private bucket is not configured (STACK_S3_PRIVATE_BUCKET). Session recordings will not be available.");
}
const s3Client = HAS_S3 ? new S3Client({
region: S3_REGION,
endpoint: S3_ENDPOINT,
@ -39,13 +44,19 @@ export async function uploadBytes(options: {
body: Uint8Array,
contentType?: string,
contentEncoding?: string,
private?: boolean,
}) {
if (!s3Client) {
throw new StackAssertionError("S3 is not configured");
}
const bucket = options.private ? S3_PRIVATE_BUCKET : S3_BUCKET;
if (!bucket) {
throw new StackAssertionError(options.private ? "S3 private bucket is not configured" : "S3 bucket is not configured");
}
const command = new PutObjectCommand({
Bucket: S3_BUCKET,
Bucket: bucket,
Key: options.key,
Body: options.body,
...(options.contentType ? { ContentType: options.contentType } : {}),
@ -56,7 +67,6 @@ export async function uploadBytes(options: {
return {
key: options.key,
url: getS3PublicUrl(options.key),
};
}
@ -87,13 +97,18 @@ async function readBodyToBytes(body: unknown): Promise<Uint8Array> {
throw new StackAssertionError("Unexpected S3 body type");
}
export async function downloadBytes(options: { key: string }): Promise<Uint8Array> {
export async function downloadBytes(options: { key: string, private?: boolean }): Promise<Uint8Array> {
if (!s3Client) {
throw new StackAssertionError("S3 is not configured");
}
const bucket = options.private ? S3_PRIVATE_BUCKET : S3_BUCKET;
if (!bucket) {
throw new StackAssertionError(options.private ? "S3 private bucket is not configured" : "S3 bucket is not configured");
}
const command = new GetObjectCommand({
Bucket: S3_BUCKET,
Bucket: bucket,
Key: options.key,
});

View File

@ -193,7 +193,7 @@ services:
ports:
- "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}21:9090"
environment:
- initialBuckets=stack-storage
- initialBuckets=stack-storage,stack-storage-private
- root=s3mockroot
- debug=false
volumes:

View File

@ -52,6 +52,7 @@ services:
STACK_SPOTIFY_CLIENT_SECRET: "MOCK"
STACK_S3_ENDPOINT: "http://localhost:32205"
STACK_S3_BUCKET: "stack-storage"
STACK_S3_PRIVATE_BUCKET: "stack-storage-private"
STACK_S3_REGION: "us-east-1"
STACK_S3_ACCESS_KEY_ID: "S3RVER"
STACK_S3_SECRET_ACCESS_KEY: "S3RVER"
@ -145,7 +146,7 @@ services:
ports:
- 32205:9090
environment:
- initialBuckets=stack-storage
- initialBuckets=stack-storage,stack-storage-private
- root=s3mockroot
- debug=false
volumes:

View File

@ -37,5 +37,6 @@ STACK_S3_REGION=
STACK_S3_ACCESS_KEY_ID=
STACK_S3_SECRET_ACCESS_KEY=
STACK_S3_BUCKET=
STACK_S3_PRIVATE_BUCKET=
STACK_FREESTYLE_API_KEY=# enter your freestyle.sh api key

View File

@ -35,7 +35,7 @@ export type AdminAuthApplicationOptions = ServerAuthApplicationOptions &(
superSecretAdminKey: string,
}
| {
projectOwnerSession: InternalSession,
projectOwnerSession: InternalSession | (() => Promise<string | null>),
}
);

View File

@ -42,7 +42,7 @@ export type ClientInterfaceOptions = {
} & ({
publishableClientKey: string,
} | {
projectOwnerSession: InternalSession,
projectOwnerSession: InternalSession | (() => Promise<string | null>),
});
export class StackClientInterface {
@ -244,6 +244,28 @@ export class StackClientInterface {
return session;
}
async sendSessionRecordingBatch(
body: string,
session: InternalSession | null,
options: { keepalive: boolean },
): Promise<Result<Response, Error>> {
try {
const response = await this.sendClientRequest(
"/session-recordings/batch",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body,
keepalive: options.keepalive,
},
session,
);
return Result.ok(response);
} catch (e) {
return Result.error(e instanceof Error ? e : new Error(String(e)));
}
}
protected async sendClientRequestAndCatchKnownError<E extends typeof KnownErrors[keyof KnownErrors]>(
path: string,
requestOptions: RequestInit,
@ -286,8 +308,25 @@ export class StackClientInterface {
*/
let tokenObj = await session.getOrFetchLikelyValidTokens(20_000, null);
let adminSession = "projectOwnerSession" in this.options ? this.options.projectOwnerSession : null;
let adminTokenObj = adminSession ? await adminSession.getOrFetchLikelyValidTokens(20_000, null) : null;
let adminSession: InternalSession | null = null;
let adminTokenObj: { accessToken: AccessToken, refreshToken: RefreshToken | null } | null = null;
if ("projectOwnerSession" in this.options) {
const projectOwnerSession = this.options.projectOwnerSession;
if (typeof projectOwnerSession === 'function') {
const accessTokenString = await projectOwnerSession();
if (accessTokenString) {
const accessToken = AccessToken.createIfValid(accessTokenString);
if (accessToken) {
adminTokenObj = { accessToken, refreshToken: null };
}
}
} else {
adminSession = projectOwnerSession;
adminTokenObj = await projectOwnerSession.getOrFetchLikelyValidTokens(20_000, null);
}
}
// all requests should be dynamic to prevent Next.js caching
await this.options.prepareRequest?.();

View File

@ -33,7 +33,7 @@ export type ServerAuthApplicationOptions = (
readonly secretServerKey: string,
}
| {
readonly projectOwnerSession: InternalSession,
readonly projectOwnerSession: InternalSession | (() => Promise<string | null>),
}
)
);

View File

@ -1,11 +1,10 @@
"use client";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import React, { useEffect, useMemo, useRef } from "react";
import { useStackApp } from "../lib/hooks";
import { stackAppInternalsSymbol } from "../lib/stack-app/common";
import { clientVersion, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultPublishableClientKey } from "../lib/stack-app/apps/implementations/common";
export type AnalyticsReplayOptions = {
enabled?: boolean,
@ -85,14 +84,11 @@ export function StackAnalyticsInternal(props: { replayOptions?: AnalyticsReplayO
// calls getUser() -> /users/me on every invocation (bypassing the cache).
// These hooks subscribe to the cache and only trigger network requests when needed.
const accessToken = app.useAccessToken();
const refreshToken = app.useRefreshToken();
// Refs so the effect closure always has the latest token values
// without needing tokens in the dependency array (which would restart recording).
// Ref so the effect closure always has the latest token value
// without needing it in the dependency array (which would restart recording).
const accessTokenRef = useRef(accessToken);
const refreshTokenRef = useRef(refreshToken);
accessTokenRef.current = accessToken;
refreshTokenRef.current = refreshToken;
useEffect(() => {
let cancelled = false;
@ -116,9 +112,7 @@ export function StackAnalyticsInternal(props: { replayOptions?: AnalyticsReplayO
};
const flush = async (options: { keepalive: boolean }) => {
const currentAccessToken = accessTokenRef.current;
const currentRefreshToken = refreshTokenRef.current;
if (!currentAccessToken) return;
if (!accessTokenRef.current) return;
if (events.length === 0) return;
const nowMs = Date.now();
@ -137,30 +131,10 @@ export function StackAnalyticsInternal(props: { replayOptions?: AnalyticsReplayO
events = [];
approxBytes = 0;
const constructorOptions = app[stackAppInternalsSymbol].getConstructorOptions();
const baseUrl = getBaseUrl(constructorOptions.baseUrl);
const publishableClientKey = constructorOptions.publishableClientKey ?? getDefaultPublishableClientKey();
const extraRequestHeaders = constructorOptions.extraRequestHeaders ?? getDefaultExtraRequestHeaders();
const res = await Result.fromThrowingAsync(async () => {
return await fetch(new URL("/api/v1/session-recordings/batch", baseUrl), {
method: "POST",
credentials: "omit",
keepalive: options.keepalive,
headers: {
"content-type": "application/json",
"x-stack-project-id": app.projectId,
"x-stack-access-type": "client",
"x-stack-client-version": clientVersion,
"x-stack-access-token": currentAccessToken,
...(currentRefreshToken ? { "x-stack-refresh-token": currentRefreshToken } : {}),
"x-stack-publishable-client-key": publishableClientKey,
"x-stack-allow-anonymous-user": "true",
...extraRequestHeaders,
},
body: JSON.stringify(payload),
});
});
const res = await app[stackAppInternalsSymbol].sendSessionRecordingBatch(
JSON.stringify(payload),
{ keepalive: options.keepalive },
);
if (res.status === "error") {
// This is best-effort telemetry. Don't throw and break the app.

View File

@ -2830,6 +2830,9 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
});
},
getConstructorOptions: () => this._options,
sendSessionRecordingBatch: async (body: string, options: { keepalive: boolean }) => {
return await this._interface.sendSessionRecordingBatch(body, await this._getSession(), options);
},
sendRequest: async (
path: string,
requestOptions: RequestInit,

View File

@ -80,7 +80,7 @@ export type StackAdminAppConstructorOptions<HasTokenStore extends boolean, Proje
& StackServerAppConstructorOptions<HasTokenStore, ProjectId>
& {
superSecretAdminKey?: string,
projectOwnerSession?: InternalSession,
projectOwnerSession?: InternalSession | (() => Promise<string | null>),
}
);

View File

@ -93,6 +93,7 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
toClientJson(): StackClientAppJson<HasTokenStore, ProjectId>,
setCurrentUser(userJsonPromise: Promise<CurrentUserCrud['Client']['Read'] | null>): void,
getConstructorOptions(): StackClientAppConstructorOptions<HasTokenStore, ProjectId> & { inheritsFrom?: undefined },
sendSessionRecordingBatch(body: string, options: { keepalive: boolean }): Promise<Result<Response, Error>>,
},
}
& AsyncStoreProperty<"project", [], Project, false>