mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Merge branch 'analytics-replays-3' into analytics-replays-4
This commit is contained in:
commit
c4d6503131
@ -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=
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -300,6 +300,7 @@ model SessionRecording {
|
||||
chunks SessionRecordingChunk[]
|
||||
|
||||
@@id([tenancyId, id])
|
||||
@@unique([tenancyId, refreshTokenId])
|
||||
@@index([tenancyId, projectUserId, startedAt])
|
||||
@@index([tenancyId, lastEventAt])
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -35,7 +35,7 @@ export type AdminAuthApplicationOptions = ServerAuthApplicationOptions &(
|
||||
superSecretAdminKey: string,
|
||||
}
|
||||
| {
|
||||
projectOwnerSession: InternalSession,
|
||||
projectOwnerSession: InternalSession | (() => Promise<string | null>),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@ -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?.();
|
||||
|
||||
@ -33,7 +33,7 @@ export type ServerAuthApplicationOptions = (
|
||||
readonly secretServerKey: string,
|
||||
}
|
||||
| {
|
||||
readonly projectOwnerSession: InternalSession,
|
||||
readonly projectOwnerSession: InternalSession | (() => Promise<string | null>),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -80,7 +80,7 @@ export type StackAdminAppConstructorOptions<HasTokenStore extends boolean, Proje
|
||||
& StackServerAppConstructorOptions<HasTokenStore, ProjectId>
|
||||
& {
|
||||
superSecretAdminKey?: string,
|
||||
projectOwnerSession?: InternalSession,
|
||||
projectOwnerSession?: InternalSession | (() => Promise<string | null>),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user