diff --git a/.env.example b/.env.example
index e7d185c..8b31b18 100644
--- a/.env.example
+++ b/.env.example
@@ -9,6 +9,13 @@ LOCAL_DATABASE_PATH=.data/postgres
GOOGLE_CLIENT_ID=
# Comma-separated Google-verified administrator emails; empty denies all admin access.
ADMIN_EMAILS=
+# GA4 Measurement Protocol API secret stays server-side (Secret Manager in production).
+GA4_MEASUREMENT_ID=
+GA4_API_SECRET=
+# Amplitude ingestion key stays server-side; no query/admin secret is needed.
+AMPLITUDE_API_KEY=
+AMPLITUDE_REGION=US
+AMPLITUDE_PROJECT_URL=
SESSION_SECRET=replace-with-at-least-32-random-characters
RUNNER_TOKEN=replace-with-a-different-32-character-random-value
LLM_PROVIDER=gemini
diff --git a/apps/server/src/admin.ts b/apps/server/src/admin.ts
index f752e1e..acd0ebc 100644
--- a/apps/server/src/admin.ts
+++ b/apps/server/src/admin.ts
@@ -71,7 +71,7 @@ export async function registerAdmin(app: FastifyInstance, db: DB) {
(SELECT count(DISTINCT user_id)::int FROM visitor_days,clock WHERE day>=date_trunc('week',today)::date AND day<=today) AS weekly,
(SELECT count(DISTINCT user_id)::int FROM visitor_days,clock WHERE day>=date_trunc('month',today)::date AND day<=today) AS monthly,
(SELECT count(*)::int FROM users) AS users,
- (SELECT count(*)::int FROM users WHERE created_at >= $1::date AT TIME ZONE 'Asia/Seoul' AND created_at < ($2::date+1) AT TIME ZONE 'Asia/Seoul') AS new_users,
+ (SELECT count(*)::int FROM users WHERE created_at >= $1::date::timestamp AT TIME ZONE 'Asia/Seoul' AND created_at < ($2::date+1)::timestamp AT TIME ZONE 'Asia/Seoul') AS new_users,
(SELECT count(DISTINCT user_id)::int FROM visitor_days WHERE day BETWEEN $1::date AND $2::date) AS period_visitors,
(SELECT count(*)::int FROM support_tickets WHERE status!='RESOLVED') AS open_inquiries,
(SELECT min(first_seen) FROM visitor_days) AS tracking_since`,
@@ -86,7 +86,7 @@ export async function registerAdmin(app: FastifyInstance, db: DB) {
SELECT date_trunc($3,day)::date AS bucket,count(DISTINCT user_id)::int AS visitors FROM visitor_days WHERE day BETWEEN $1::date AND $2::date GROUP BY 1
), joined AS (
SELECT date_trunc($3,created_at AT TIME ZONE 'Asia/Seoul')::date AS bucket,count(*)::int AS new_users FROM users
- WHERE created_at >= $1::date AT TIME ZONE 'Asia/Seoul' AND created_at < ($2::date+1) AT TIME ZONE 'Asia/Seoul' GROUP BY 1
+ WHERE created_at >= $1::date::timestamp AT TIME ZONE 'Asia/Seoul' AND created_at < ($2::date+1)::timestamp AT TIME ZONE 'Asia/Seoul' GROUP BY 1
) SELECT to_char(b.bucket,'YYYY-MM-DD') AS period,CASE WHEN (SELECT min(day) FROM visitor_days) IS NULL OR (b.bucket+('1 ' || $3)::interval)::date <= (SELECT min(day) FROM visitor_days) THEN NULL ELSE coalesce(v.visitors,0)::int END AS visitors,coalesce(j.new_users,0)::int AS new_users
FROM buckets b LEFT JOIN visitors v USING(bucket) LEFT JOIN joined j USING(bucket) ORDER BY b.bucket`,
[...args, range.grain],
@@ -100,7 +100,7 @@ export async function registerAdmin(app: FastifyInstance, db: DB) {
).rows;
const revenue = (
await tx.query(
- `SELECT currency,sum(CASE WHEN kind='PAYMENT' THEN amount ELSE 0 END)::text AS gross,sum(CASE WHEN kind='REFUND' THEN amount ELSE 0 END)::text AS refunds,sum(CASE WHEN kind='PAYMENT' THEN amount ELSE -amount END)::text AS net FROM revenue_entries WHERE occurred_at >= $1::date AT TIME ZONE 'Asia/Seoul' AND occurred_at < ($2::date+1) AT TIME ZONE 'Asia/Seoul' GROUP BY currency`,
+ `SELECT currency,sum(CASE WHEN kind='PAYMENT' THEN amount ELSE 0 END)::text AS gross,sum(CASE WHEN kind='REFUND' THEN amount ELSE 0 END)::text AS refunds,sum(CASE WHEN kind='PAYMENT' THEN amount ELSE -amount END)::text AS net FROM revenue_entries WHERE occurred_at >= $1::date::timestamp AT TIME ZONE 'Asia/Seoul' AND occurred_at < ($2::date+1)::timestamp AT TIME ZONE 'Asia/Seoul' GROUP BY currency`,
args,
)
).rows;
@@ -112,19 +112,19 @@ export async function registerAdmin(app: FastifyInstance, db: DB) {
).rows;
const revenueSeries = (
await tx.query(
- `SELECT to_char(date_trunc($3,occurred_at AT TIME ZONE 'Asia/Seoul'),'YYYY-MM-DD') AS period,currency,sum(CASE WHEN kind='PAYMENT' THEN amount ELSE -amount END)::text AS amount FROM revenue_entries WHERE occurred_at >= $1::date AT TIME ZONE 'Asia/Seoul' AND occurred_at < ($2::date+1) AT TIME ZONE 'Asia/Seoul' GROUP BY 1,2 ORDER BY 1,2`,
+ `SELECT to_char(date_trunc($3,occurred_at AT TIME ZONE 'Asia/Seoul'),'YYYY-MM-DD') AS period,currency,sum(CASE WHEN kind='PAYMENT' THEN amount ELSE -amount END)::text AS amount FROM revenue_entries WHERE occurred_at >= $1::date::timestamp AT TIME ZONE 'Asia/Seoul' AND occurred_at < ($2::date+1)::timestamp AT TIME ZONE 'Asia/Seoul' GROUP BY 1,2 ORDER BY 1,2`,
[...args, range.grain],
)
).rows;
const usage = (
await tx.query(
- `SELECT provider,model,count(*)::int AS calls,count(input_tokens)::int AS measured_calls,sum(input_tokens)::text AS input_tokens,sum(output_tokens)::text AS output_tokens,sum(thinking_tokens)::text AS thinking_tokens FROM review_api_usage WHERE created_at >= $1::date AT TIME ZONE 'Asia/Seoul' AND created_at < ($2::date+1) AT TIME ZONE 'Asia/Seoul' GROUP BY provider,model`,
+ `SELECT provider,model,count(*)::int AS calls,count(input_tokens)::int AS measured_calls,sum(input_tokens)::text AS input_tokens,sum(output_tokens)::text AS output_tokens,sum(thinking_tokens)::text AS thinking_tokens FROM review_api_usage WHERE created_at >= $1::date::timestamp AT TIME ZONE 'Asia/Seoul' AND created_at < ($2::date+1)::timestamp AT TIME ZONE 'Asia/Seoul' GROUP BY provider,model`,
args,
)
).rows;
const events = (
await tx.query(
- `SELECT type,count(*)::int AS count FROM business_events WHERE created_at >= $1::date AT TIME ZONE 'Asia/Seoul' AND created_at < ($2::date+1) AT TIME ZONE 'Asia/Seoul' GROUP BY type ORDER BY count DESC`,
+ `SELECT type,count(*)::int AS count FROM business_events WHERE created_at >= $1::date::timestamp AT TIME ZONE 'Asia/Seoul' AND created_at < ($2::date+1)::timestamp AT TIME ZONE 'Asia/Seoul' GROUP BY type ORDER BY count DESC`,
args,
)
).rows;
@@ -168,7 +168,7 @@ export async function registerAdmin(app: FastifyInstance, db: DB) {
.parse(req.query);
const rows = (
await db.query(
- `SELECT id,type,actor_id,entity_id,metadata,created_at FROM business_events WHERE created_at >= $1::date AT TIME ZONE 'Asia/Seoul' AND created_at < ($2::date+1) AT TIME ZONE 'Asia/Seoul' AND ($3::text IS NULL OR type=$3) ORDER BY created_at DESC,id LIMIT 51 OFFSET $4`,
+ `SELECT id,type,actor_id,entity_id,metadata,created_at FROM business_events WHERE created_at >= $1::date::timestamp AT TIME ZONE 'Asia/Seoul' AND created_at < ($2::date+1)::timestamp AT TIME ZONE 'Asia/Seoul' AND ($3::text IS NULL OR type=$3) ORDER BY created_at DESC,id LIMIT 51 OFFSET $4`,
[range.from, range.to, type ?? null, (page - 1) * 50],
)
).rows;
diff --git a/apps/server/src/amplitude.ts b/apps/server/src/amplitude.ts
new file mode 100644
index 0000000..eeecb1e
--- /dev/null
+++ b/apps/server/src/amplitude.ts
@@ -0,0 +1,184 @@
+import { randomUUID } from "node:crypto";
+import type { DB } from "./db.js";
+import {
+ eventProperties,
+ ga4EventName,
+ safeEventProperties,
+ type BusinessEventType,
+} from "./event-catalog.js";
+export type AmplitudeConfig = { apiKey: string; region: "US" | "EU" };
+export function amplitudeConfig(
+ env = process.env,
+): AmplitudeConfig | undefined {
+ if (!env.AMPLITUDE_API_KEY) return undefined;
+ const region = env.AMPLITUDE_REGION || "US";
+ if (
+ !/^[a-zA-Z0-9_-]{16,256}$/.test(env.AMPLITUDE_API_KEY) ||
+ !["US", "EU"].includes(region)
+ )
+ throw Error("INVALID_AMPLITUDE_CONFIGURATION");
+ return { apiKey: env.AMPLITUDE_API_KEY, region: region as "US" | "EU" };
+}
+export function amplitudeEvent(row: Record) {
+ if (!Object.hasOwn(eventProperties, row.type))
+ throw Error("UNKNOWN_EVENT_TYPE");
+ const context = row.analytics_context ?? {};
+ return {
+ event_type: ga4EventName(row.type),
+ insert_id: row.id,
+ // Stable per-account fallback; do not leak Google IDs, email or DB row IDs.
+ device_id:
+ context.clientId ?? "server." + (row.analytics_id ?? "algostep-system"),
+ ...(row.analytics_id ? { user_id: row.analytics_id } : {}),
+ time: new Date(row.created_at).getTime(),
+ session_id:
+ context.clientId &&
+ Number.isSafeInteger(context.sessionId) &&
+ context.sessionId > 0
+ ? context.sessionId * 1000
+ : -1,
+ event_properties: {
+ ...safeEventProperties(row.type as BusinessEventType, row.metadata),
+ event_source: context.clientId ? "web_context" : "server_unattributed",
+ },
+ // No IP, device fingerprint, raw URLs, user properties or session replay.
+ };
+}
+export async function deliverAmplitude(
+ db: DB,
+ config = amplitudeConfig(),
+ send: typeof fetch = fetch,
+ limit = 250,
+) {
+ if (!config) return { configured: false, processed: 0 };
+ await db.query(
+ "UPDATE amplitude_deliveries SET status='PENDING',lease_token=NULL,lease_until=NULL,last_code='LEASE_RECOVERED',updated_at=now() WHERE status='PROCESSING' AND lease_until {
+ const rows = (
+ await tx.query(
+ "SELECT e.*,u.analytics_id::text AS analytics_id,d.payload,d.attempts FROM amplitude_deliveries d JOIN business_events e ON e.id=d.event_id LEFT JOIN users u ON u.id=e.actor_id WHERE d.status='PENDING' AND d.available_at<=now() ORDER BY d.available_at,d.event_id LIMIT $1 FOR UPDATE OF d SKIP LOCKED",
+ [Math.min(10, limit - processed)],
+ )
+ ).rows;
+ const valid: Record[] = [];
+ for (const row of rows) {
+ try {
+ const payload = row.payload ?? amplitudeEvent(row);
+ await tx.query(
+ "UPDATE amplitude_deliveries SET status='PROCESSING',attempts=attempts+1,first_attempt_at=COALESCE(first_attempt_at,now()),payload=$2,lease_token=$3,lease_until=now()+interval '90 seconds',updated_at=now() WHERE event_id=$1",
+ [row.id, JSON.stringify(payload), token],
+ );
+ valid.push({ ...row, payload, attempts: row.attempts + 1 });
+ } catch (error) {
+ // Only schema validation is handled here; DB failure rolls back.
+ if (
+ (error as any).name !== "ZodError" &&
+ (error as Error).message !== "UNKNOWN_EVENT_TYPE"
+ )
+ throw error;
+ await tx.query(
+ "UPDATE amplitude_deliveries SET status='REJECTED',last_code='INVALID_PAYLOAD',updated_at=now() WHERE event_id=$1",
+ [row.id],
+ );
+ }
+ }
+ return { valid, claimed: rows.length };
+ });
+ if (!rows.claimed) break;
+ processed += rows.claimed;
+ if (!rows.valid.length) continue;
+ let status = "PENDING",
+ code = "TRANSPORT_ERROR";
+ let delay = Math.min(
+ 3600,
+ 60 * 2 ** Math.min(6, Math.max(...rows.valid.map((r) => r.attempts))),
+ );
+ try {
+ const response = await send(
+ config.region === "EU"
+ ? "https://api.eu.amplitude.com/2/httpapi"
+ : "https://api2.amplitude.com/2/httpapi",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ api_key: config.apiKey,
+ events: rows.valid.map((r) => r.payload),
+ }),
+ redirect: "error",
+ signal: AbortSignal.timeout(5000),
+ },
+ );
+ if (response.status === 200) {
+ const body = (await response.json()) as any;
+ if (
+ body.code === 200 &&
+ Number.isInteger(body.events_ingested) &&
+ body.events_ingested >= 0 &&
+ body.events_ingested <= rows.valid.length
+ ) {
+ status = "INGESTED";
+ code =
+ body.events_ingested === rows.valid.length
+ ? "INGESTED"
+ : "INGESTED_OR_DEDUPLICATED";
+ delay = 0;
+ } else code = "INGESTION_UNCONFIRMED";
+ } else if (response.status === 429 || response.status >= 500)
+ code = "HTTP_" + response.status;
+ else {
+ status = "REJECTED";
+ code = "HTTP_" + response.status;
+ delay = 0;
+ }
+ } catch {
+ /* Never log provider errors, payloads or credentials. */
+ }
+ await db.query(
+ "UPDATE amplitude_deliveries SET status=$3,last_code=$4,available_at=now()+$5*interval '1 second',lease_token=NULL,lease_until=NULL,updated_at=now() WHERE event_id=ANY($1::uuid[]) AND lease_token=$2",
+ [rows.valid.map((r) => r.id), token, status, code, delay],
+ );
+ }
+ return { configured: true, processed };
+}
+export async function amplitudeStatus(db: DB) {
+ const config = amplitudeConfig();
+ let url: string | null = null;
+ try {
+ const u = new URL(process.env.AMPLITUDE_PROJECT_URL ?? "");
+ if (
+ u.protocol === "https:" &&
+ [
+ "app.amplitude.com",
+ "analytics.amplitude.com",
+ "app.eu.amplitude.com",
+ "analytics.eu.amplitude.com",
+ ].includes(u.hostname) &&
+ !u.username &&
+ !u.password &&
+ !u.search &&
+ !u.hash
+ )
+ url = u.href;
+ } catch {}
+ return {
+ configured: !!config,
+ region: config?.region ?? null,
+ url,
+ states: (
+ await db.query(
+ "SELECT status,last_code,count(*)::int AS count,min(available_at) AS oldest FROM amplitude_deliveries GROUP BY status,last_code ORDER BY status",
+ )
+ ).rows,
+ };
+}
diff --git a/apps/server/src/analytics-context.ts b/apps/server/src/analytics-context.ts
new file mode 100644
index 0000000..440d657
--- /dev/null
+++ b/apps/server/src/analytics-context.ts
@@ -0,0 +1,19 @@
+import { AsyncLocalStorage } from "node:async_hooks";
+export type AnalyticsContext = { clientId?: string; sessionId?: number };
+export const analyticsContext = new AsyncLocalStorage();
+export function readAnalyticsContext(
+ headers: Record,
+): AnalyticsContext {
+ const client = headers["x-analytics-client-id"],
+ session = headers["x-analytics-session-id"];
+ return {
+ ...(typeof client === "string" && /^\d{1,20}\.\d{1,20}$/.test(client)
+ ? { clientId: client }
+ : {}),
+ ...(typeof session === "string" &&
+ /^\d{1,12}$/.test(session) &&
+ Number(session) > 0
+ ? { sessionId: Number(session) }
+ : {}),
+ };
+}
diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts
index 83ea2a7..86461fe 100644
--- a/apps/server/src/app.ts
+++ b/apps/server/src/app.ts
@@ -25,6 +25,9 @@ import { externalize, hydrate, type ObjectStorage } from "./object-storage.js";
import { maintenance } from "./maintenance.js";
import { registerAdmin } from "./admin.js";
import { businessEvent, visit } from "./business.js";
+import { analyticsContext, readAnalyticsContext } from "./analytics-context.js";
+import { registerClientEvents } from "./client-events.js";
+import { ga4Config } from "./ga4.js";
type Config = {
db: DB;
@@ -93,6 +96,9 @@ export async function createApp(c: Config) {
logger: process.env.NODE_ENV !== "test",
bodyLimit: 2 * 1024 * 1024,
});
+ app.addHook("onRequest", (req, _reply, done) =>
+ analyticsContext.run(readAnalyticsContext(req.headers), done),
+ );
await app.register(cookie);
await app.register(cors, { origin: c.origin, credentials: true });
await app.register(rateLimit, {
@@ -229,7 +235,7 @@ export async function createApp(c: Config) {
);
if (
rows[0].client === "android" &&
- !/^\/api\/(me|topics|quiz\/[^/?]+|auth\/logout|analytics\/visit|support)(\?|$)/.test(
+ !/^\/api\/(me|topics|quiz\/[^/?]+|auth\/logout|analytics\/(visit|events)|support)(\?|$)/.test(
req.url,
)
)
@@ -249,6 +255,7 @@ export async function createApp(c: Config) {
);
});
await registerAdmin(app, c.db);
+ await registerClientEvents(app, c.db);
const user = (req: any) => req.user.id as string;
app.get("/api/operations/metrics", async () => ({
reviewApiUsage30Days: (
@@ -305,6 +312,7 @@ export async function createApp(c: Config) {
);
app.get("/api/health", async () => ({ ok: true }));
app.get("/api/config", async () => ({
+ ga4MeasurementId: ga4Config()?.measurementId ?? null,
googleClientId: c.googleClientId,
reviewEnabled: c.llmEnabled,
paymentEnabled: false,
@@ -389,7 +397,14 @@ export async function createApp(c: Config) {
path: "/",
maxAge: 7 * 86400,
});
- return { user: { ...account, isAdmin: isAdmin(account) }, csrf };
+ return {
+ user: {
+ ...account,
+ isAdmin: isAdmin(account),
+ analyticsId: account.analytics_id,
+ },
+ csrf,
+ };
},
);
app.get("/api/me", async (req: any) => {
@@ -400,14 +415,18 @@ export async function createApp(c: Config) {
name: req.user.display_name,
email: req.user.email,
isAdmin: isAdmin(req.user),
+ analyticsId: req.user.analytics_id,
},
csrf: req.user.csrf,
};
});
app.post("/api/auth/logout", async (req: any, reply) => {
- await c.db.query("DELETE FROM sessions WHERE id=$1", [
- sessionDigest(req.cookies.session),
- ]);
+ await c.db.tx(async (db) => {
+ await db.query("DELETE FROM sessions WHERE id=$1", [
+ sessionDigest(req.cookies.session),
+ ]);
+ await businessEvent(db, "LOGOUT", user(req), null, "logout:" + id());
+ });
reply.clearCookie("session", { path: "/" });
return { ok: true };
});
@@ -557,17 +576,27 @@ export async function createApp(c: Config) {
.extend({ revision: z.number().int().nonnegative() })
.parse(req.body);
await ownRecord(c.db, user(req), uuid.parse(req.params.id));
- const { rows } = await c.db.query(
- "UPDATE records SET source=$1,language=$2,revision=revision+1,updated_at=now() WHERE id=$3 AND user_id=$4 AND revision=$5 AND expires_at>now() RETURNING *",
- [v.source, v.language, req.params.id, user(req), v.revision],
- );
- if (!rows[0])
- throw new DomainError(
- 409,
- "SAVE_CONFLICT",
- "다른 화면에서 코드가 변경됐어요. 현재 코드를 복사한 뒤 최신 기록을 다시 열어 주세요.",
+ return c.db.tx(async (db) => {
+ const { rows } = await db.query(
+ "UPDATE records SET source=$1,language=$2,revision=revision+1,updated_at=now() WHERE id=$3 AND user_id=$4 AND revision=$5 AND expires_at>now() RETURNING *",
+ [v.source, v.language, req.params.id, user(req), v.revision],
+ );
+ if (!rows[0])
+ throw new DomainError(
+ 409,
+ "SAVE_CONFLICT",
+ "다른 화면에서 코드가 변경됐어요. 현재 코드를 복사한 뒤 최신 기록을 다시 열어 주세요.",
+ );
+ await businessEvent(
+ db,
+ "CODE_SAVED",
+ user(req),
+ rows[0].id,
+ "save:" + rows[0].id + ":" + rows[0].revision,
+ { language: rows[0].language, revision: rows[0].revision },
);
- return rows[0];
+ return rows[0];
+ });
});
app.delete("/api/records/:id", async (req: any) => {
await c.db.tx(async (db) => {
@@ -745,6 +774,13 @@ export async function createApp(c: Config) {
execution: await hydrate(c.storage, execution),
reviews,
});
+ await businessEvent(
+ c.db,
+ "PDF_GENERATED",
+ user(req),
+ record.id,
+ "pdf:" + id(),
+ );
return reply
.type("application/pdf")
.header(
@@ -779,8 +815,8 @@ export async function createApp(c: Config) {
if (busy >= fleet.slots) return { job: null };
}
- await db.query(
- "UPDATE executions SET status='FAILED',result=$1,finished_at=now(),lease_token=NULL WHERE status='RUNNING' AND lease_expires_at= 10) return { job: null };
const e = (
await db.query(
- "SELECT e.* FROM executions e JOIN records r ON r.id=e.record_id WHERE e.status='QUEUED' AND r.expires_at>now() ORDER BY e.created_at LIMIT 1 FOR UPDATE OF e SKIP LOCKED",
+ "SELECT e.*,r.user_id FROM executions e JOIN records r ON r.id=e.record_id WHERE e.status='QUEUED' AND r.expires_at>now() ORDER BY e.created_at LIMIT 1 FOR UPDATE OF e SKIP LOCKED",
)
).rows[0];
if (!e) return { job: null };
@@ -810,6 +862,14 @@ export async function createApp(c: Config) {
"UPDATE executions SET status='RUNNING',attempt=attempt+1,lease_token=$2,lease_expires_at=now()+interval '90 seconds',worker_name=$3 WHERE id=$1",
[e.id, token, fleet ? worker : null],
);
+ await businessEvent(
+ db,
+ "EXECUTION_STARTED",
+ e.user_id,
+ e.id,
+ "execution-start:" + e.id,
+ { attempt: e.attempt + 1 },
+ );
const info = (
await db.query(
"SELECT s.language,s.source,p.tests FROM code_snapshots s JOIN records r ON r.id=s.record_id JOIN problems p ON p.id=r.problem_id WHERE s.id=$1",
diff --git a/apps/server/src/business.ts b/apps/server/src/business.ts
index ccdeb61..8ec761e 100644
--- a/apps/server/src/business.ts
+++ b/apps/server/src/business.ts
@@ -1,25 +1,49 @@
import type { DB } from "./db.js";
import { randomUUID } from "node:crypto";
+import {
+ safeEventProperties,
+ type BusinessEventType,
+} from "./event-catalog.js";
+import { analyticsContext } from "./analytics-context.js";
// Only purpose-selected non-content metadata belongs here: never code, prompts,
// tokens, passwords, request bodies or inquiry contents.
export async function businessEvent(
db: DB,
- type: string,
+ type: BusinessEventType,
actor: string | null,
entity: string | null,
key: string,
metadata: Record = {},
) {
await db.query(
- "INSERT INTO business_events(id,event_key,type,actor_id,entity_id,metadata) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(event_key) DO NOTHING",
- [randomUUID(), key, type, actor, entity, JSON.stringify(metadata)],
+ `WITH inserted AS (
+ INSERT INTO business_events(id,event_key,type,actor_id,entity_id,metadata,analytics_context)
+ VALUES($1,$2,$3,$4,$5,$6,CASE WHEN $7::jsonb!='{}'::jsonb THEN $7::jsonb ELSE COALESCE((SELECT analytics_context FROM business_events WHERE actor_id=$4 AND entity_id=$5 AND analytics_context!='{}'::jsonb ORDER BY created_at LIMIT 1),'{}'::jsonb) END)
+ ON CONFLICT(event_key) DO NOTHING RETURNING id), ga4 AS (
+ INSERT INTO analytics_deliveries(event_id) SELECT id FROM inserted RETURNING event_id)
+ INSERT INTO amplitude_deliveries(event_id) SELECT event_id FROM ga4`,
+ [
+ randomUUID(),
+ key,
+ type,
+ actor,
+ entity,
+ JSON.stringify(safeEventProperties(type, metadata)),
+ JSON.stringify(analyticsContext.getStore() ?? {}),
+ ],
);
}
export async function visit(db: DB, user: string) {
await db.query(
- "INSERT INTO visitor_days(user_id,day) VALUES($1,(now() AT TIME ZONE 'Asia/Seoul')::date) ON CONFLICT DO NOTHING",
- [user],
+ `WITH v AS (
+ INSERT INTO visitor_days(user_id,day) VALUES($1,(now() AT TIME ZONE 'Asia/Seoul')::date) ON CONFLICT DO NOTHING RETURNING day
+ ), e AS (
+ INSERT INTO business_events(id,event_key,type,actor_id,metadata,analytics_context)
+ SELECT $2,'visit:' || $1 || ':' || day::text,'DAILY_VISIT',$1,'{}'::jsonb,$3::jsonb FROM v ON CONFLICT(event_key) DO NOTHING RETURNING id
+ ), ga4 AS (INSERT INTO analytics_deliveries(event_id) SELECT id FROM e RETURNING event_id)
+ INSERT INTO amplitude_deliveries(event_id) SELECT event_id FROM ga4`,
+ [user, randomUUID(), JSON.stringify(analyticsContext.getStore() ?? {})],
);
}
diff --git a/apps/server/src/client-events.ts b/apps/server/src/client-events.ts
new file mode 100644
index 0000000..962739d
--- /dev/null
+++ b/apps/server/src/client-events.ts
@@ -0,0 +1,70 @@
+import type { FastifyInstance } from "fastify";
+import type { DB } from "./db.js";
+import { z } from "zod";
+import { businessEvent } from "./business.js";
+import {
+ clientEventTypes,
+ eventProperties,
+ safeEventProperties,
+} from "./event-catalog.js";
+import { analyticsStatus } from "./ga4.js";
+import { DomainError } from "./domain.js";
+export async function registerClientEvents(app: FastifyInstance, db: DB) {
+ app.get("/api/admin/analytics", () => analyticsStatus(db));
+ app.post(
+ "/api/analytics/events",
+ { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
+ async (req: any) => {
+ const { events } = z
+ .object({
+ events: z
+ .array(
+ z
+ .object({
+ requestKey: z.string().uuid(),
+ type: z.enum(clientEventTypes),
+ properties: z.record(z.string(), z.unknown()),
+ })
+ .strict(),
+ )
+ .min(1)
+ .max(25),
+ })
+ .strict()
+ .parse(req.body);
+ // Validate the whole batch before writing anything and disallow spoofed
+ // server actions (revenue, execution result, signup, review consumption).
+ const parsed = events.map((e) => ({
+ ...e,
+ properties: eventProperties[e.type].strict().parse(e.properties),
+ }));
+ await db.tx(async (tx) => {
+ for (const e of parsed) {
+ const properties = safeEventProperties(e.type, e.properties);
+ if (
+ properties.topic &&
+ !(
+ await tx.query("SELECT id FROM topics WHERE id=$1", [
+ properties.topic,
+ ])
+ ).rows.length
+ )
+ throw new DomainError(
+ 400,
+ "UNKNOWN_ANALYTICS_TOPIC",
+ "학습 주제를 확인해 주세요.",
+ );
+ await businessEvent(
+ tx,
+ e.type,
+ req.user.id,
+ null,
+ "client:" + req.user.id + ":" + e.requestKey,
+ properties,
+ );
+ }
+ });
+ return { accepted: events.length };
+ },
+ );
+}
diff --git a/apps/server/src/content-admin.ts b/apps/server/src/content-admin.ts
index 325f38c..f62cf9e 100644
--- a/apps/server/src/content-admin.ts
+++ b/apps/server/src/content-admin.ts
@@ -1,6 +1,8 @@
import { readFile } from "node:fs/promises";
import { z } from "zod";
import { database } from "./db.js";
+import { businessEvent } from "./business.js";
+import { randomUUID } from "node:crypto";
const short = z.string().min(1).max(200),
code = z.string().min(1).max(65536);
const languages = z.object({ python: code, cpp: code, java: code });
@@ -105,6 +107,14 @@ if (process.argv[1]?.replaceAll("\\", "/").match(/content-admin\.(ts|js)$/)) {
}),
],
);
+ await businessEvent(
+ tx,
+ "CONTENT_PUBLISHED",
+ null,
+ null,
+ "publish:" + randomUUID(),
+ { topics: data.topics.length, problems: data.problems.length },
+ );
});
console.log("Catalog published atomically.");
} finally {
diff --git a/apps/server/src/event-catalog.ts b/apps/server/src/event-catalog.ts
new file mode 100644
index 0000000..0af0bb0
--- /dev/null
+++ b/apps/server/src/event-catalog.ts
@@ -0,0 +1,125 @@
+import { z } from "zod";
+// A closed vocabulary: free text, code, URLs, search terms and identity details
+// are never analytical properties, even if a client tries to submit them.
+const code = z.string().regex(/^[a-zA-Z0-9_-]{1,80}$/);
+const small = z.number().int().min(0).max(1000000);
+const language = z.enum(["python", "cpp", "java"]);
+const screen = z.enum([
+ "home",
+ "lesson",
+ "practice",
+ "challenges",
+ "problems",
+ "workspace",
+ "history",
+ "support",
+ "admin",
+]);
+export const eventProperties = {
+ USER_REGISTERED: z.object({ client: z.enum(["web", "android"]) }),
+ LOGIN: z.object({ client: z.enum(["web", "android"]) }),
+ LOGOUT: z.object({}),
+ DAILY_VISIT: z.object({}),
+ QUIZ_ANSWERED: z.object({ correct: z.boolean(), topic: code }),
+ PRACTICE_STARTED: z.object({ problem: code, language }),
+ CODE_SAVED: z.object({ language, revision: small }),
+ EXECUTION_REQUESTED: z.object({
+ mode: z.enum(["judge", "custom"]),
+ language,
+ problem: code,
+ }),
+ EXECUTION_STARTED: z.object({ attempt: small }),
+ EXECUTION_FINISHED: z.object({
+ status: z.enum(["FAILED", "SUCCEEDED"]),
+ verdict: code,
+ }),
+ REVIEW_REQUESTED: z.object({ language }),
+ REVIEW_STARTED: z.object({}),
+ REVIEW_SUCCEEDED: z.object({
+ model: z.string().regex(/^[a-zA-Z0-9._/-]{1,80}$/),
+ }),
+ REVIEW_FAILED: z.object({ reason: code }),
+ RECORD_DELETED: z.object({}),
+ PDF_GENERATED: z.object({}),
+ INQUIRY_CREATED: z.object({
+ category: z.enum(["question", "bug", "billing", "other"]),
+ }),
+ INQUIRY_UPDATED: z.object({
+ status: z.enum(["OPEN", "IN_PROGRESS", "RESOLVED"]),
+ }),
+ COSTS_IMPORTED: z.object({ entries: small }),
+ CONTENT_PUBLISHED: z.object({ topics: small, problems: small }),
+ SCREEN_VIEWED: z.object({ screen, topic: code.optional() }),
+ EXAMPLE_SELECTED: z.object({ topic: code, index: small }),
+ EXAMPLE_APPLIED: z.object({
+ topic: code,
+ count: small,
+ success: z.boolean(),
+ }),
+ VISUALIZATION_CONTROL: z.object({
+ action: z.enum([
+ "previous",
+ "next",
+ "first",
+ "last",
+ "play",
+ "pause",
+ "seek",
+ "layout",
+ "zoom_in",
+ "zoom_out",
+ "fit",
+ "reset",
+ "move_node",
+ ]),
+ step: small.optional(),
+ }),
+ QUIZ_VIEWED: z.object({ topic: code, index: small }),
+ QUIZ_RETRIED: z.object({ topic: code }),
+ LANGUAGE_SELECTED: z.object({ language }),
+ RESULT_TAB_VIEWED: z.object({
+ tab: z.enum(["visual", "tests", "analysis", "review"]),
+ }),
+ TEST_SELECTED: z.object({ index: small }),
+ PDF_EXPORTED: z.object({
+ destination: z.enum(["download", "drive"]),
+ success: z.boolean(),
+ }),
+ SEARCH_USED: z.object({ result_count: small }),
+ CATEGORY_SELECTED: z.object({ index: small }),
+ THEME_CHANGED: z.object({ theme: z.enum(["light", "dark"]) }),
+ CLIENT_ERROR: z.object({ code: code }),
+ ERROR_RECOVERY: z.object({ action: z.enum(["retry", "home", "reload"]) }),
+ ADMIN_SECTION_VIEWED: z.object({
+ section: z.enum(["overview", "finance", "users", "support", "events"]),
+ }),
+} as const;
+export type BusinessEventType = keyof typeof eventProperties;
+export const clientEventTypes = [
+ "SCREEN_VIEWED",
+ "EXAMPLE_SELECTED",
+ "EXAMPLE_APPLIED",
+ "VISUALIZATION_CONTROL",
+ "QUIZ_VIEWED",
+ "QUIZ_RETRIED",
+ "LANGUAGE_SELECTED",
+ "RESULT_TAB_VIEWED",
+ "TEST_SELECTED",
+ "PDF_EXPORTED",
+ "SEARCH_USED",
+ "CATEGORY_SELECTED",
+ "THEME_CHANGED",
+ "CLIENT_ERROR",
+ "ERROR_RECOVERY",
+ "ADMIN_SECTION_VIEWED",
+] as const;
+export function safeEventProperties(
+ type: BusinessEventType,
+ properties: unknown,
+) {
+ return eventProperties[type].parse(properties) as Record<
+ string,
+ string | number | boolean
+ >;
+}
+export const ga4EventName = (type: string) => "as_" + type.toLowerCase();
diff --git a/apps/server/src/ga4.ts b/apps/server/src/ga4.ts
new file mode 100644
index 0000000..a05daab
--- /dev/null
+++ b/apps/server/src/ga4.ts
@@ -0,0 +1,210 @@
+import type { DB } from "./db.js";
+import { createHash, randomUUID } from "node:crypto";
+import {
+ eventProperties,
+ ga4EventName,
+ safeEventProperties,
+ type BusinessEventType,
+} from "./event-catalog.js";
+
+export type Ga4Config = { measurementId: string; apiSecret: string };
+export function ga4Config(env = process.env): Ga4Config | undefined {
+ if (!env.GA4_MEASUREMENT_ID || !env.GA4_API_SECRET) return undefined;
+ if (
+ !/^G-[A-Z0-9]{4,20}$/.test(env.GA4_MEASUREMENT_ID) ||
+ env.GA4_API_SECRET.length > 256 ||
+ /\s/.test(env.GA4_API_SECRET)
+ )
+ throw new Error("INVALID_GA4_CONFIGURATION");
+ return {
+ measurementId: env.GA4_MEASUREMENT_ID,
+ apiSecret: env.GA4_API_SECRET,
+ };
+}
+export function ga4Payload(row: Record) {
+ if (!Object.hasOwn(eventProperties, row.type))
+ throw new Error("UNKNOWN_EVENT_TYPE");
+ const properties = safeEventProperties(
+ row.type as BusinessEventType,
+ row.metadata,
+ );
+ const context = row.analytics_context ?? {};
+ // GA4 requires number.number even for server-only events. Hash the opaque
+ // analytics identity into that format, without inventing a browser session.
+ const fallback = createHash("sha256")
+ .update("algostep-ga4:" + (row.analytics_id ?? "system"))
+ .digest();
+ const params: Record = {
+ event_id: row.id,
+ event_source: context.clientId ? "web_context" : "server_unattributed",
+ };
+ for (const [key, value] of Object.entries(properties))
+ params[key] = typeof value === "boolean" ? (value ? 1 : 0) : value;
+ if (
+ context.clientId &&
+ Number.isSafeInteger(context.sessionId) &&
+ context.sessionId > 0
+ )
+ params.session_id = context.sessionId;
+ return {
+ client_id:
+ context.clientId ??
+ `${fallback.readUInt32BE(0)}.${fallback.readUInt32BE(4)}`,
+ ...(row.analytics_id ? { user_id: row.analytics_id } : {}),
+ timestamp_micros: new Date(row.created_at).getTime() * 1000,
+ consent: { ad_user_data: "DENIED", ad_personalization: "DENIED" },
+ validation_behavior: "ENFORCE_RECOMMENDATIONS",
+ events: [
+ {
+ name: ga4EventName(row.type),
+ timestamp_micros: new Date(row.created_at).getTime() * 1000,
+ params,
+ },
+ ],
+ };
+}
+
+// A 2xx collect response means accepted at the HTTP layer, not proven ingestion.
+// Google provides no general idempotency contract: ambiguous collect responses
+// are quarantined, never automatically resent. Operational metrics use the exact DB ledger.
+export async function deliverGa4(
+ db: DB,
+ config = ga4Config(),
+ send: typeof fetch = fetch,
+ limit = 250,
+) {
+ if (!config) return { configured: false, processed: 0 };
+ const started = Date.now();
+ let processed = 0;
+ await db.query(
+ "UPDATE analytics_deliveries SET status='UNCERTAIN',last_code='LEASE_EXPIRED',lease_token=NULL,lease_until=NULL,updated_at=now() WHERE status='PROCESSING' AND lease_until {
+ const next = (
+ await tx.query(
+ "SELECT d.event_id,e.actor_id,e.analytics_context FROM analytics_deliveries d JOIN business_events e ON e.id=d.event_id WHERE d.status='PENDING' AND d.available_at<=now() ORDER BY d.available_at,d.event_id LIMIT 1 FOR UPDATE OF d SKIP LOCKED",
+ )
+ ).rows[0];
+ if (!next) return [];
+ // One request has one client/user context. Preserve each event's original
+ // timestamp when batching up to the provider's 25-event limit.
+ const batch = (
+ await tx.query(
+ "SELECT d.event_id FROM analytics_deliveries d JOIN business_events e ON e.id=d.event_id WHERE d.status='PENDING' AND d.available_at<=now() AND e.actor_id IS NOT DISTINCT FROM $1::uuid AND e.analytics_context=$2::jsonb ORDER BY d.available_at,d.event_id LIMIT $3 FOR UPDATE OF d SKIP LOCKED",
+ [
+ next.actor_id,
+ JSON.stringify(next.analytics_context),
+ Math.min(25, limit - processed),
+ ],
+ )
+ ).rows;
+ const ids = batch.map((r) => r.event_id);
+ await tx.query(
+ "UPDATE analytics_deliveries SET status='PROCESSING',lease_token=$2,lease_until=now()+interval '90 seconds',attempts=attempts+1,updated_at=now() WHERE event_id=ANY($1::uuid[])",
+ [ids, token],
+ );
+ return (
+ await tx.query(
+ "SELECT e.*,u.analytics_id::text AS analytics_id,d.attempts FROM business_events e LEFT JOIN users u ON u.id=e.actor_id JOIN analytics_deliveries d ON d.event_id=e.id WHERE e.id=ANY($1::uuid[])",
+ [ids],
+ )
+ ).rows;
+ });
+ if (!rows.length) break;
+ processed += rows.length;
+ let status = "REJECTED",
+ code = "INVALID_PAYLOAD",
+ collectStarted = false,
+ validationStarted = false,
+ delay = 0;
+ try {
+ const payload = ga4Payload(rows[0]);
+ payload.events = rows.flatMap((row) => ga4Payload(row).events);
+ const query = new URLSearchParams({
+ measurement_id: config.measurementId,
+ api_secret: config.apiSecret,
+ });
+ const options = {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(payload),
+ redirect: "error" as const,
+ };
+ validationStarted = true;
+ const checked = await send(
+ "https://www.google-analytics.com/debug/mp/collect?" + query,
+ { ...options, signal: AbortSignal.timeout(5000) },
+ );
+ if (!checked.ok) {
+ code = "VALIDATION_HTTP_" + checked.status;
+ } else {
+ const result = (await checked.json()) as any;
+ if (!Array.isArray(result.validationMessages)) {
+ code = "INVALID_VALIDATION_RESPONSE";
+ } else if (result.validationMessages.length) {
+ code = "VALIDATION_REJECTED";
+ } else {
+ collectStarted = true;
+ const response = await send(
+ "https://www.google-analytics.com/mp/collect?" + query,
+ { ...options, signal: AbortSignal.timeout(5000) },
+ );
+ status = response.ok ? "ACCEPTED" : "REJECTED";
+ code = response.ok
+ ? "HTTP_ACCEPTED"
+ : "COLLECT_HTTP_" + response.status;
+ }
+ }
+ } catch {
+ // Never log provider exceptions: their message may contain the secret URL.
+ if (collectStarted) {
+ status = "UNCERTAIN";
+ code = "COLLECT_RESPONSE_UNKNOWN";
+ } else if (
+ validationStarted &&
+ Math.max(...rows.map((r) => r.attempts)) < 5
+ ) {
+ status = "PENDING";
+ code = "VALIDATION_UNAVAILABLE";
+ delay = Math.min(
+ 3600,
+ 60 * 2 ** Math.max(...rows.map((r) => r.attempts)),
+ );
+ } else if (validationStarted) {
+ status = "REJECTED";
+ code = "VALIDATION_RETRY_EXHAUSTED";
+ }
+ }
+ await db.query(
+ "UPDATE analytics_deliveries SET status=$3,last_code=$4,available_at=now()+$5*interval '1 second',lease_token=NULL,lease_until=NULL,updated_at=now() WHERE event_id=ANY($1::uuid[]) AND lease_token=$2",
+ [rows.map((r) => r.id), token, status, code, delay],
+ );
+ }
+ return { configured: true, processed };
+}
+
+export async function analyticsStatus(db: DB) {
+ const config = ga4Config();
+ const { amplitudeStatus } = await import("./amplitude.js");
+ return {
+ ga4: {
+ configured: !!config,
+ measurementId: config?.measurementId ?? null,
+ states: (
+ await db.query(
+ "SELECT status,last_code,count(*)::int AS count,min(available_at) AS oldest FROM analytics_deliveries GROUP BY status,last_code ORDER BY status",
+ )
+ ).rows,
+ },
+ amplitude: await amplitudeStatus(db),
+ catalog: Object.entries(eventProperties).map(([type]) => ({
+ type,
+ ga4Name: ga4EventName(type),
+ })),
+ };
+}
diff --git a/apps/server/src/maintenance.ts b/apps/server/src/maintenance.ts
index 59efa60..f985e48 100644
--- a/apps/server/src/maintenance.ts
+++ b/apps/server/src/maintenance.ts
@@ -4,6 +4,8 @@ import type { DB } from "./db.js";
import { cleanExpired } from "./domain.js";
import { processReview, recoverReviews } from "./review-worker.js";
import { deleteObjects, type ObjectStorage } from "./object-storage.js";
+import { deliverGa4 } from "./ga4.js";
+import { deliverAmplitude } from "./amplitude.js";
export async function maintenance(db: DB, storage?: ObjectStorage) {
await cleanExpired(db);
await recoverReviews(db);
@@ -22,5 +24,12 @@ export async function maintenance(db: DB, storage?: ObjectStorage) {
}
const llm = llmOptions();
if (llm) await processReview(db, llm);
+ // Independent destinations: one provider failure must not starve the other.
+ const delivered = await Promise.allSettled([
+ deliverGa4(db),
+ deliverAmplitude(db),
+ ]);
+ if (delivered.some((r) => r.status === "rejected"))
+ throw new Error("ANALYTICS_MAINTENANCE_FAILED");
if (fleetError) throw fleetError;
}
diff --git a/apps/server/src/migrations.ts b/apps/server/src/migrations.ts
index 284a861..f4176fa 100644
--- a/apps/server/src/migrations.ts
+++ b/apps/server/src/migrations.ts
@@ -46,6 +46,31 @@ const migrations = [
CREATE INDEX revenue_entries_date ON revenue_entries(occurred_at);
`,
},
+ {
+ version: 6,
+ sql: `
+ ALTER TABLE users ADD COLUMN analytics_id uuid NOT NULL DEFAULT gen_random_uuid();
+ CREATE UNIQUE INDEX users_analytics_id ON users(analytics_id);
+ ALTER TABLE business_events ADD COLUMN analytics_context jsonb NOT NULL DEFAULT '{}';
+ CREATE TABLE analytics_deliveries(event_id uuid PRIMARY KEY REFERENCES business_events ON DELETE CASCADE,status text NOT NULL DEFAULT 'PENDING' CHECK(status IN ('PENDING','PROCESSING','ACCEPTED','REJECTED','UNCERTAIN','EXPIRED')),attempts integer NOT NULL DEFAULT 0,available_at timestamptz NOT NULL DEFAULT now(),lease_token uuid,lease_until timestamptz,last_code text,updated_at timestamptz NOT NULL DEFAULT now());
+ CREATE INDEX analytics_deliveries_queue ON analytics_deliveries(status,available_at);
+ INSERT INTO analytics_deliveries(event_id) SELECT id FROM business_events;
+ CREATE VIEW analytics_events WITH (security_barrier=true) AS SELECT e.id AS event_id,e.type AS event_type,'as_' || lower(e.type) AS ga4_event_name,u.analytics_id::text AS user_id,e.created_at,e.created_at AT TIME ZONE 'Asia/Seoul' AS occurred_at_kst,e.metadata FROM business_events e LEFT JOIN users u ON u.id=e.actor_id;
+ CREATE VIEW analytics_visitors WITH (security_barrier=true) AS SELECT u.analytics_id::text AS user_id,v.day AS day_kst FROM visitor_days v JOIN users u ON u.id=v.user_id;
+ CREATE VIEW analytics_users WITH (security_barrier=true) AS SELECT analytics_id::text AS user_id,created_at,created_at AT TIME ZONE 'Asia/Seoul' AS registered_at_kst FROM users;
+ CREATE VIEW analytics_costs WITH (security_barrier=true) AS SELECT usage_date AS day_kst,service,currency,sum(amount) AS amount FROM cost_entries GROUP BY usage_date,service,currency;
+ CREATE VIEW analytics_revenue WITH (security_barrier=true) AS SELECT (occurred_at AT TIME ZONE 'Asia/Seoul')::date AS day_kst,currency,kind,sum(amount) AS amount FROM revenue_entries GROUP BY 1,2,3;
+ CREATE VIEW analytics_inquiries WITH (security_barrier=true) AS SELECT (created_at AT TIME ZONE 'Asia/Seoul')::date AS day_kst,category,status,count(*) AS inquiries FROM support_tickets GROUP BY 1,2,3;
+ CREATE VIEW analytics_delivery_status WITH (security_barrier=true) AS SELECT e.type AS event_type,d.status,d.last_code,count(*) AS events,min(e.created_at) AS oldest_event,max(d.updated_at) AS last_update FROM analytics_deliveries d JOIN business_events e ON e.id=d.event_id GROUP BY 1,2,3;
+ REVOKE ALL ON analytics_events,analytics_visitors,analytics_users,analytics_costs,analytics_revenue,analytics_inquiries,analytics_delivery_status FROM PUBLIC;
+ `,
+ },
+ {
+ version: 7,
+ sql: `CREATE TABLE amplitude_deliveries(event_id uuid PRIMARY KEY REFERENCES business_events ON DELETE CASCADE,status text NOT NULL DEFAULT 'PENDING' CHECK(status IN ('PENDING','PROCESSING','INGESTED','REJECTED','EXPIRED')),attempts integer NOT NULL DEFAULT 0,available_at timestamptz NOT NULL DEFAULT now(),first_attempt_at timestamptz,lease_token uuid,lease_until timestamptz,payload jsonb,last_code text,updated_at timestamptz NOT NULL DEFAULT now());
+ CREATE INDEX amplitude_deliveries_queue ON amplitude_deliveries(status,available_at);
+ INSERT INTO amplitude_deliveries(event_id) SELECT id FROM business_events;`,
+ },
];
export async function migrate(db: DB) {
diff --git a/apps/server/src/review-worker.ts b/apps/server/src/review-worker.ts
index e570048..b8cd4c8 100644
--- a/apps/server/src/review-worker.ts
+++ b/apps/server/src/review-worker.ts
@@ -20,6 +20,13 @@ export async function processReview(db: DB, options: LlmOptions) {
"UPDATE reviews SET status='RUNNING',started_at=now() WHERE id=$1",
[r.id],
);
+ await businessEvent(
+ tx,
+ "REVIEW_STARTED",
+ r.user_id,
+ r.id,
+ "review-start:" + r.id,
+ );
return r;
});
if (!task) return false;
diff --git a/apps/server/test/admin.test.ts b/apps/server/test/admin.test.ts
index 297a90d..be43a96 100644
--- a/apps/server/test/admin.test.ts
+++ b/apps/server/test/admin.test.ts
@@ -310,8 +310,8 @@ test("cost import is atomic and repeatable; currencies and refunds stay separate
});
assert.equal(operational.statusCode, 200);
await db.query(
- "INSERT INTO revenue_entries(id,provider_event_id,kind,currency,amount,occurred_at) VALUES($1,'payment-001','PAYMENT','KRW',3000,now()),($2,'refund-001','REFUND','KRW',1000,now())",
- [id(), id()],
+ "INSERT INTO revenue_entries(id,provider_event_id,kind,currency,amount,occurred_at) VALUES($1,'payment-001','PAYMENT','KRW',3000,$3::date::timestamp AT TIME ZONE 'Asia/Seoul'),($2,'refund-001','REFUND','KRW',1000,($3::date::timestamp+interval '1 minute') AT TIME ZONE 'Asia/Seoul'),($4,'previous-day','PAYMENT','KRW',9999,($3::date::timestamp-interval '1 second') AT TIME ZONE 'Asia/Seoul'),($5,'next-day','PAYMENT','KRW',9999,($3::date+1)::timestamp AT TIME ZONE 'Asia/Seoul')",
+ [id(), id(), day, id(), id()],
);
const r = await request(
admin,
@@ -374,7 +374,7 @@ test("business events remain after record deletion and retry keys do not inflate
);
await assert.rejects(
db.tx(async (tx) => {
- await businessEvent(tx, "ROLLBACK_EVENT", student.id, null, "rollback");
+ await businessEvent(tx, "LOGOUT", student.id, null, "rollback");
throw new Error("rollback");
}),
);
diff --git a/apps/server/test/amplitude.test.ts b/apps/server/test/amplitude.test.ts
new file mode 100644
index 0000000..ee76651
--- /dev/null
+++ b/apps/server/test/amplitude.test.ts
@@ -0,0 +1,133 @@
+import { test, before, after } from "node:test";
+import assert from "node:assert/strict";
+import { database, type DB } from "../src/db.js";
+import { businessEvent } from "../src/business.js";
+import {
+ amplitudeConfig,
+ amplitudeEvent,
+ deliverAmplitude,
+ amplitudeStatus,
+} from "../src/amplitude.js";
+let db: DB;
+const config = { apiKey: "test-amplitude-key", region: "US" as const };
+before(async () => {
+ db = await database();
+});
+after(async () => {
+ await db.close();
+});
+const reset = () => db.query("DELETE FROM business_events");
+const add = (key: string) => businessEvent(db, "LOGOUT", null, null, key);
+const states = async () =>
+ (await db.query("SELECT * FROM amplitude_deliveries ORDER BY event_id")).rows;
+const success: typeof fetch = async (_url, options) =>
+ new Response(
+ JSON.stringify({
+ code: 200,
+ events_ingested: JSON.parse(String(options?.body)).events.length,
+ }),
+ );
+test("both destinations are atomically deduplicated and rollback together", async () => {
+ await reset();
+ await add("same");
+ await add("same");
+ assert.equal((await states()).length, 1);
+ assert.equal(
+ (await db.query("SELECT * FROM analytics_deliveries")).rows.length,
+ 1,
+ );
+ await assert.rejects(
+ db.tx(async (tx) => {
+ await businessEvent(tx, "LOGOUT", null, null, "rollback");
+ throw Error("rollback");
+ }),
+ );
+ assert.equal((await states()).length, 1);
+});
+test("Amplitude batches at most ten and uses fixed regional hosts without exposing secrets", async () => {
+ await reset();
+ for (let i = 0; i < 12; i++) await add("batch-" + i);
+ const sizes: number[] = [];
+ await deliverAmplitude(
+ db,
+ { ...config, region: "EU" },
+ async (url, options) => {
+ assert.equal(url, "https://api.eu.amplitude.com/2/httpapi");
+ const body = JSON.parse(String(options?.body));
+ sizes.push(body.events.length);
+ assert.equal(body.api_key, config.apiKey);
+ assert.equal(body.events[0].session_id, -1);
+ return success(url, options);
+ },
+ );
+ assert.deepEqual(sizes, [10, 2]);
+ assert.ok((await states()).every((r) => r.status === "INGESTED"));
+ assert.doesNotMatch(
+ JSON.stringify(await amplitudeStatus(db)),
+ /test-amplitude-key/,
+ );
+});
+test("ambiguous transport retries preserve payload and insert ID even after source changes", async () => {
+ await reset();
+ await add("retry");
+ let original: any;
+ await deliverAmplitude(db, config, async (_u, o) => {
+ original = JSON.parse(String(o?.body)).events[0];
+ throw Error("network");
+ });
+ assert.equal((await states())[0].status, "PENDING");
+ await db.query(
+ 'UPDATE business_events SET analytics_context=\'{"clientId":"111.222","sessionId":123}\'',
+ );
+ await db.query(
+ "UPDATE amplitude_deliveries SET available_at=now()-interval '1 second'",
+ );
+ await deliverAmplitude(db, config, async (u, o) => {
+ assert.deepEqual(JSON.parse(String(o?.body)).events[0], original);
+ return success(u, o);
+ });
+ assert.equal((await states())[0].status, "INGESTED");
+ assert.equal((await states())[0].attempts, 2);
+});
+test("rate limits retry, invalid requests reject, stale attempts expire", async () => {
+ for (const [code, status] of [
+ [429, "PENDING"],
+ [503, "PENDING"],
+ [400, "REJECTED"],
+ ] as const) {
+ await reset();
+ await add("status-" + code);
+ await deliverAmplitude(
+ db,
+ config,
+ async () => new Response("{}", { status: code }),
+ );
+ assert.equal((await states())[0].status, status);
+ }
+ await reset();
+ await add("expired");
+ await db.query(
+ "UPDATE amplitude_deliveries SET first_attempt_at=now()-interval '7 days'",
+ );
+ await deliverAmplitude(db, config, async () => {
+ throw Error("must not send");
+ });
+ assert.equal((await states())[0].status, "EXPIRED");
+});
+test("expired lease can retry with stable payload; missing configuration does not send", async () => {
+ await reset();
+ await add("lease");
+ await db.query(
+ "UPDATE amplitude_deliveries SET status='PROCESSING',lease_until=now()-interval '1 second'",
+ );
+ await deliverAmplitude(db, config, success);
+ assert.equal((await states())[0].status, "INGESTED");
+ assert.equal(amplitudeConfig({}), undefined);
+ assert.throws(() =>
+ amplitudeConfig({
+ AMPLITUDE_API_KEY: "test-amplitude-key",
+ AMPLITUDE_REGION: "other",
+ }),
+ );
+ assert.throws(() => amplitudeEvent({ type: "UNKNOWN" }));
+});
diff --git a/apps/server/test/analytics.test.ts b/apps/server/test/analytics.test.ts
new file mode 100644
index 0000000..1a9fd69
--- /dev/null
+++ b/apps/server/test/analytics.test.ts
@@ -0,0 +1,350 @@
+import { test, before, after } from "node:test";
+import assert from "node:assert/strict";
+import { randomUUID } from "node:crypto";
+import { readFileSync } from "node:fs";
+import { database, type DB } from "../src/db.js";
+import { createApp } from "../src/app.js";
+import { seed } from "../src/content.js";
+import { businessEvent, visit } from "../src/business.js";
+import {
+ analyticsContext,
+ readAnalyticsContext,
+} from "../src/analytics-context.js";
+import { ga4Payload, deliverGa4, ga4Config } from "../src/ga4.js";
+import { eventProperties, ga4EventName } from "../src/event-catalog.js";
+process.env.NODE_ENV = "test";
+let db: DB,
+ app: Awaited>,
+ actor: string,
+ cookie: string,
+ csrf: string,
+ analyticsId: string;
+const origin = "http://localhost:5173";
+const config = { measurementId: "G-TEST12345", apiSecret: "test-only" };
+before(async () => {
+ db = await database();
+ await seed(db);
+ app = await createApp({
+ db,
+ origin,
+ googleClientId: "test",
+ sessionSecret: "test",
+ runnerToken: "test",
+ llmEnabled: false,
+ adminEmails: ["admin@example.test"],
+ verifyGoogle: async () => ({
+ sub: "analytics-admin",
+ name: "Admin",
+ email: "admin@example.test",
+ }),
+ });
+ const r = await app.inject({
+ method: "POST",
+ url: "/api/auth/google",
+ headers: { origin },
+ payload: { credential: "analytics-test-token" },
+ });
+ assert.equal(r.statusCode, 200, r.body);
+ cookie = String(r.headers["set-cookie"]).split(";")[0];
+ csrf = r.json().csrf;
+ actor = r.json().user.id;
+ analyticsId = r.json().user.analyticsId;
+});
+after(async () => {
+ await app.close();
+ await db.close();
+});
+const reset = () => db.query("DELETE FROM business_events");
+const add = (key: string = randomUUID()) =>
+ businessEvent(db, "LOGOUT", actor, null, key);
+const statuses = async () =>
+ (
+ await db.query(
+ "SELECT status,last_code,attempts FROM analytics_deliveries ORDER BY event_id",
+ )
+ ).rows;
+const post = (events: any[], headers: Record = {}) =>
+ app.inject({
+ method: "POST",
+ url: "/api/analytics/events",
+ headers: { origin, cookie, "x-csrf-token": csrf, ...headers },
+ payload: { events },
+ });
+const okFetch: typeof fetch = async (url, options) =>
+ new Response(
+ String(url).includes("/debug/")
+ ? JSON.stringify({ validationMessages: [] })
+ : null,
+ { status: String(url).includes("/debug/") ? 200 : 204 },
+ );
+
+test("transactional outbox deduplicates, rolls back, and daily visits are one event per KST day", async () => {
+ await reset();
+ await add("same");
+ await add("same");
+ assert.equal((await statuses()).length, 1);
+ await assert.rejects(
+ db.tx(async (tx) => {
+ await businessEvent(tx, "LOGOUT", actor, null, "rollback");
+ throw Error("rollback");
+ }),
+ );
+ assert.equal((await statuses()).length, 1);
+ await db.query("DELETE FROM visitor_days");
+ await visit(db, actor);
+ await visit(db, actor);
+ assert.equal(
+ (
+ await db.query(
+ "SELECT count(*)::int AS n FROM business_events WHERE type='DAILY_VISIT'",
+ )
+ ).rows[0].n,
+ 1,
+ );
+});
+test("client API rejects server-event spoofing, content fields, unknown topics and missing CSRF; duplicate retries do not double count", async () => {
+ await reset();
+ const e = {
+ requestKey: randomUUID(),
+ type: "SCREEN_VIEWED",
+ properties: { screen: "home" },
+ };
+ assert.equal((await post([e], { "x-csrf-token": "" })).statusCode, 403);
+ assert.equal(
+ (
+ await post([
+ { ...e, type: "REVIEW_SUCCEEDED", properties: { model: "fake" } },
+ ])
+ ).statusCode,
+ 400,
+ );
+ assert.equal(
+ (
+ await post([
+ { ...e, properties: { screen: "home", email: "private@example.test" } },
+ ])
+ ).statusCode,
+ 400,
+ );
+ assert.equal(
+ (
+ await post([
+ e,
+ {
+ ...e,
+ requestKey: randomUUID(),
+ properties: { screen: "lesson", topic: "missing-topic" },
+ },
+ ])
+ ).statusCode,
+ 400,
+ );
+ assert.equal((await statuses()).length, 0);
+ const headers = {
+ "x-analytics-client-id": "123456.123456",
+ "x-analytics-session-id": "123456789",
+ };
+ assert.equal((await post([e], headers)).statusCode, 200);
+ assert.equal((await post([e], headers)).statusCode, 200);
+ assert.equal((await statuses()).length, 1);
+ const row = (await db.query("SELECT * FROM business_events")).rows[0];
+ assert.deepEqual(row.analytics_context, {
+ clientId: "123456.123456",
+ sessionId: 123456789,
+ });
+ assert.equal((await app.inject("/api/admin/analytics")).statusCode, 401);
+ const status = await app.inject({
+ url: "/api/admin/analytics",
+ headers: { cookie },
+ });
+ assert.equal(status.statusCode, 200);
+ assert.equal(
+ status.json().catalog.length,
+ Object.keys(eventProperties).length,
+ );
+});
+test("GA4 payload excludes raw identity, entity IDs, extra properties and matches browser context", () => {
+ const payload = ga4Payload({
+ id: randomUUID(),
+ type: "SCREEN_VIEWED",
+ created_at: new Date(),
+ analytics_id: analyticsId,
+ actor_id: actor,
+ entity_id: "private-record",
+ email: "private@example.test",
+ metadata: { screen: "home", code: "private code" },
+ analytics_context: { clientId: "123.456", sessionId: 123 },
+ });
+ assert.equal(payload.client_id, "123.456");
+ assert.equal(payload.user_id, analyticsId);
+ assert.equal(payload.events[0].params.session_id, 123);
+ assert.doesNotMatch(JSON.stringify(payload), /private|actor_id|entity_id/);
+ assert.deepEqual(
+ readAnalyticsContext({
+ "x-analytics-client-id": "someone@example.test",
+ "x-analytics-session-id": "NaN",
+ }),
+ {},
+ );
+ for (const type of Object.keys(eventProperties))
+ assert.ok(/^[a-z][a-z0-9_]{0,39}$/.test(ga4EventName(type)));
+ assert.equal(ga4Config({}), undefined);
+ assert.throws(() =>
+ ga4Config({ GA4_MEASUREMENT_ID: "bad", GA4_API_SECRET: "secret" }),
+ );
+});
+test("GA4 server-only client ID is numeric, stable and does not invent session attribution", () => {
+ const row = {
+ id: randomUUID(),
+ type: "LOGOUT",
+ created_at: new Date(),
+ analytics_id: analyticsId,
+ metadata: {},
+ };
+ const payload = ga4Payload(row);
+ assert.match(payload.client_id, /^\d+\.\d+$/);
+ assert.equal(
+ ga4Payload({ ...row, id: randomUUID() }).client_id,
+ payload.client_id,
+ );
+ assert.notEqual(
+ ga4Payload({ ...row, analytics_id: randomUUID() }).client_id,
+ payload.client_id,
+ );
+ assert.equal(payload.events[0].params.event_source, "server_unattributed");
+ assert.equal(payload.events[0].params.session_id, undefined);
+ assert.equal(payload.events[0].params.engagement_time_msec, undefined);
+});
+
+test("async server result inherits the originating request context", async () => {
+ await reset();
+ const entity = randomUUID();
+ await analyticsContext.run({ clientId: "123.456", sessionId: 123 }, () =>
+ businessEvent(db, "EXECUTION_REQUESTED", actor, entity, "req", {
+ mode: "judge",
+ language: "python",
+ problem: "sample",
+ }),
+ );
+ await businessEvent(db, "EXECUTION_FINISHED", actor, entity, "done", {
+ status: "SUCCEEDED",
+ verdict: "ACCEPTED",
+ });
+ const row = (
+ await db.query(
+ "SELECT analytics_context FROM business_events WHERE event_key='done'",
+ )
+ ).rows[0];
+ assert.deepEqual(row.analytics_context, {
+ clientId: "123.456",
+ sessionId: 123,
+ });
+});
+test("GA4 batches at most 25, preserves original timestamps and validates before collecting", async () => {
+ await reset();
+ for (let i = 0; i < 27; i++) await add();
+ const calls: { url: string; body: any }[] = [];
+ const send: typeof fetch = async (url, options) => {
+ calls.push({ url: String(url), body: JSON.parse(options!.body as string) });
+ return okFetch(url, options);
+ };
+ assert.equal((await deliverGa4(db, config, send)).processed, 27);
+ assert.equal(calls.length, 4);
+ assert.match(calls[0].url, /\/debug\//);
+ assert.doesNotMatch(calls[1].url, /\/debug\//);
+ assert.equal(calls[0].body.events.length, 25);
+ assert.equal(calls[2].body.events.length, 2);
+ assert.ok(
+ calls[0].body.events.every((e: any) =>
+ Number.isSafeInteger(e.timestamp_micros),
+ ),
+ );
+ assert.ok((await statuses()).every((r) => r.status === "ACCEPTED"));
+ assert.equal((await deliverGa4(db, config, send)).processed, 0);
+});
+test("validation rejects never collect and ambiguous delivery never retries", async () => {
+ await reset();
+ await add();
+ let calls = 0;
+ await deliverGa4(db, config, async () => {
+ calls++;
+ return Response.json({
+ validationMessages: [{ validationCode: "VALUE_INVALID" }],
+ });
+ });
+ assert.equal(calls, 1);
+ assert.equal((await statuses())[0].status, "REJECTED");
+ await reset();
+ await add();
+ calls = 0;
+ await deliverGa4(db, config, async (url, options) => {
+ calls++;
+ if (String(url).includes("/debug/")) return okFetch(url, options);
+ throw Error("simulated response loss");
+ });
+ assert.equal((await statuses())[0].status, "UNCERTAIN");
+ assert.equal(calls, 2);
+ await deliverGa4(db, config, async () => {
+ throw Error("must not retry");
+ });
+ assert.equal((await statuses())[0].attempts, 1);
+});
+test("pre-collect transport failure backs off; disabled, expired and abandoned leases never collect", async () => {
+ await reset();
+ await add();
+ await deliverGa4(db, undefined, async () => {
+ throw Error("disabled");
+ });
+ assert.equal((await statuses())[0].attempts, 0);
+ await deliverGa4(db, config, async () => {
+ throw Error("validation unavailable");
+ });
+ assert.equal((await statuses())[0].status, "PENDING");
+ assert.equal((await statuses())[0].last_code, "VALIDATION_UNAVAILABLE");
+ assert.equal((await deliverGa4(db, config, okFetch)).processed, 0);
+ await db.query(
+ "UPDATE business_events SET created_at=now()-interval '73 hours'",
+ );
+ await deliverGa4(db, config, okFetch);
+ assert.equal((await statuses())[0].status, "EXPIRED");
+ await reset();
+ await add();
+ await db.query(
+ "UPDATE analytics_deliveries SET status='PROCESSING',lease_until=now()-interval '1 second'",
+ );
+ await deliverGa4(db, config, async () => {
+ throw Error("must not resend");
+ });
+ assert.equal((await statuses())[0].status, "UNCERTAIN");
+});
+test("Internal analytics views expose pseudonymous data, all dashboard SQL runs, PUBLIC cannot select views", async () => {
+ await reset();
+ await add();
+ const row = (await db.query("SELECT * FROM analytics_events")).rows[0];
+ assert.equal(row.user_id, analyticsId);
+ assert.notEqual(row.user_id, actor);
+ assert.doesNotMatch(
+ JSON.stringify(row),
+ /admin@example|actor_id|analytics_context|entity_id/,
+ );
+ for (const q of JSON.parse(
+ readFileSync(
+ new URL("../../../scripts/business-queries.json", import.meta.url),
+ "utf8",
+ ),
+ )) {
+ // Production uses algostep; the isolated database uses public.
+ await db.query(
+ q.query.replaceAll("algostep.analytics_", "public.analytics_"),
+ );
+ }
+ await db.query("CREATE ROLE analytics_test_reader");
+ assert.equal(
+ (
+ await db.query(
+ "SELECT has_table_privilege('analytics_test_reader','analytics_events','SELECT') AS allowed",
+ )
+ ).rows[0].allowed,
+ false,
+ );
+});
diff --git a/apps/server/test/expansion.test.ts b/apps/server/test/expansion.test.ts
index 0aeae42..e2c5387 100644
--- a/apps/server/test/expansion.test.ts
+++ b/apps/server/test/expansion.test.ts
@@ -28,7 +28,7 @@ test("migration is repeatable and preserves saved data", async () => {
assert.equal(
(await db.query("SELECT max(version) AS n FROM schema_migrations"))
.rows[0].n,
- 5,
+ 7,
);
} finally {
await db.close();
diff --git a/apps/web/src/AdminDashboard.tsx b/apps/web/src/AdminDashboard.tsx
index ffce7c2..86fb23f 100644
--- a/apps/web/src/AdminDashboard.tsx
+++ b/apps/web/src/AdminDashboard.tsx
@@ -1,3 +1,4 @@
+import { track } from "./analytics.js";
import React, { useEffect, useState } from "react";
import { api, post } from "./api.js";
import { SupportPanel, Pager, displayTime } from "./SupportPanel.js";
@@ -33,6 +34,9 @@ export function AdminDashboard() {
[data, setData] = useState(null),
[error, setError] = useState(""),
[refresh, setRefresh] = useState(0);
+ useEffect(() => {
+ track("ADMIN_SECTION_VIEWED", { section: tab });
+ }, [tab]);
useEffect(() => {
let current = true;
setData(null);
@@ -438,6 +442,7 @@ function EventList({ query, events }: { query: string; events: any[] }) {
const [type, setType] = useState("");
return (
<>
+
- location.reload()}>화면 다시 열기
+ {
+ track("ERROR_RECOVERY", { action: "reload" });
+ void flushAnalytics().finally(() => location.reload());
+ }}
+ >
+ 화면 다시 열기
+
) : (
this.props.children
diff --git a/apps/web/src/visualization.tsx b/apps/web/src/visualization.tsx
index 5f2ffdf..1648dd9 100644
--- a/apps/web/src/visualization.tsx
+++ b/apps/web/src/visualization.tsx
@@ -1,3 +1,4 @@
+import { track } from "./analytics.js";
import { DiagramCanvas, graphPositions } from "./DiagramCanvas.js";
import { lessonExamples } from "./lesson-examples.js";
import React, { useEffect, useState } from "react";
@@ -69,12 +70,21 @@ export function TraceViewer({
aria-label="이전 단계"
onClick={() => {
setPlaying(false);
+ track("VISUALIZATION_CONTROL", { action: "previous", step });
setStep(Math.max(0, step - 1));
}}
>
이전
- setPlaying(!playing)}>
+ {
+ track("VISUALIZATION_CONTROL", {
+ action: playing ? "pause" : "play",
+ step,
+ });
+ setPlaying(!playing);
+ }}
+ >
{playing ? "일시 정지" : "재생"}
{
setPlaying(false);
+ track("VISUALIZATION_CONTROL", { action: "next", step });
setStep(Math.min(trace.length - 1, step + 1));
}}
>
@@ -89,6 +100,12 @@ export function TraceViewer({
+ track("VISUALIZATION_CONTROL", { action: "seek", step })
+ }
+ onKeyUp={() =>
+ track("VISUALIZATION_CONTROL", { action: "seek", step })
+ }
type="range"
min="0"
max={Math.max(0, trace.length - 1)}
@@ -348,6 +365,7 @@ function CoreLessonDiagram({ topic }: { topic: string }) {
onChange={(e) => {
const i = Number(e.target.value),
example = examples[i];
+ track("EXAMPLE_SELECTED", { topic, index: i });
setExampleIndex(i);
setCustom(false);
setText(example.values.join(", "));
@@ -379,9 +397,19 @@ function CoreLessonDiagram({ topic }: { topic: string }) {
nums.length > 8 ||
nums.some((n) => !Number.isInteger(n) || Math.abs(n) > 1000)
) {
+ track("EXAMPLE_APPLIED", {
+ topic,
+ count: Math.min(nums.length, 1000000),
+ success: false,
+ });
setInvalid("정수 1~8개를 입력하세요. 각 값은 -1000~1000이에요.");
return;
}
+ track("EXAMPLE_APPLIED", {
+ topic,
+ count: nums.length,
+ success: true,
+ });
setValues(nums);
setCustom(true);
setStep(0);
@@ -460,7 +488,10 @@ function CoreLessonDiagram({ topic }: { topic: string }) {
setStep(step - 1)}
+ onClick={() => {
+ track("VISUALIZATION_CONTROL", { action: "previous", step });
+ setStep(step - 1);
+ }}
>
이전
@@ -469,7 +500,10 @@ function CoreLessonDiagram({ topic }: { topic: string }) {
= frames.length - 1}
- onClick={() => setStep(step + 1)}
+ onClick={() => {
+ track("VISUALIZATION_CONTROL", { action: "next", step });
+ setStep(step + 1);
+ }}
>
다음 단계
diff --git a/docs/admin-dashboard.md b/docs/admin-dashboard.md
index 170e2f3..e59a86a 100644
--- a/docs/admin-dashboard.md
+++ b/docs/admin-dashboard.md
@@ -52,3 +52,7 @@ DB 마이그레이션 5에서 `visitor_days`, `business_events`, `support_ticket
## 검증
관리자 401/403과 CSRF, KST 경계, 고유 사용자 집계, 수집 전 기간, 문의 소유권/재시도/답변 충돌, 비용 통화 분리·중복·원자성·환불 집계, 기록 삭제 후 로그 보존을 통합 검사한다. Playwright로 일반 사용자→관리자→답변 확인, 비용 업로드, 차트·로그 화면, 다크모드와 모바일 너비를 확인한다. 브라우저 비용 데이터는 격리된 테스트 DB에만 입력한다.
+
+## GA4 · Amplitude 확장
+
+마이그레이션 6·7에서 36종 공통 이벤트와 GA4·Amplitude 각각의 전송 큐를 제공한다. 내부 SQL 조회 뷰는 유지한다. 관리자 비즈니스 로그에서 연결 설정 및 전송 상태를 확인한다. 외부 활성화 조건, 전체 이벤트 목록과 운영 절차는 [GA4 · Amplitude 연동](analytics-integrations.md)을 따른다.
diff --git a/docs/analytics-integrations.md b/docs/analytics-integrations.md
new file mode 100644
index 0000000..3e9f6a9
--- /dev/null
+++ b/docs/analytics-integrations.md
@@ -0,0 +1,76 @@
+# GA4 · Amplitude 비즈니스 이벤트
+
+## 상태와 데이터 흐름
+
+현재 앱의 36종 비즈니스 이벤트를 공통 원장에 저장한다. 서버 작업은 상태 변경과 같은 트랜잭션에서 `business_events`와 `analytics_deliveries`에 기록한다. UI 상호작용은 인증·CSRF가 적용된 `POST /api/analytics/events`를 통해 같은 원장으로 들어간다. GA4는 이 원장에서 Measurement Protocol로 전송하고 Amplitude는 별도 큐에서 HTTP V2 API로 전송한다.
+
+**배포 설정:** GA4 웹 측정 ID, Measurement Protocol API Secret, Amplitude 프로젝트 수집 키와 리전. 설정 전에도 DB 수집은 동작하며 관리자 → 비즈니스 로그에 `연결 설정 대기`로 표시한다. 연결 설정 완료와 외부 보고서 수신 검증은 다르다. 이 문서나 코드의 존재만으로 외부 연결 완료를 뜻하지 않는다.
+
+## 이벤트 목록
+
+GA4 이름은 아래 코드에 소문자 `as_` 접두어를 붙인다. 예: `EXECUTION_FINISHED` → `as_execution_finished`. 정확한 허용 속성과 열거형은 `apps/server/src/event-catalog.ts`가 기준이다.
+
+| 영역 | 이벤트 | 수집 시점 / 속성 |
+| --- | --- | --- |
+| 계정·방문 | USER_REGISTERED, LOGIN, LOGOUT, DAILY_VISIT | 실제 가입·로그인·로그아웃·KST 날짜별 첫 로그인 방문. client(web/android) |
+| 화면·검색 | SCREEN_VIEWED, SEARCH_USED, CATEGORY_SELECTED | 화면/주제 ID, 검색 결과 수, 카테고리 위치. 검색어 제외 |
+| 학습 예제 | EXAMPLE_SELECTED, EXAMPLE_APPLIED, VISUALIZATION_CONTROL | 주제·예제 위치, 입력 개수/적용 성공, 단계 이동·재생·배치·노드 이동. 입력값·좌표 제외 |
+| 퀴즈 | QUIZ_VIEWED, QUIZ_ANSWERED, QUIZ_RETRIED | 주제·문항 위치·정답 여부. 답안 내용 제외 |
+| 코드 작성 | PRACTICE_STARTED, CODE_SAVED, LANGUAGE_SELECTED | 문제 ID·언어·저장 revision. 키 입력별 이벤트 및 소스 코드 제외 |
+| 코드 실행 | EXECUTION_REQUESTED, EXECUTION_STARTED, EXECUTION_FINISHED | 문제·언어·실행 모드·시도·상태·판정. 실행 환경 중단 포함 |
+| 결과 탐색 | RESULT_TAB_VIEWED, TEST_SELECTED | 시각화/테스트/분석/리뷰 탭, 공개 테스트 위치 |
+| LLM 리뷰 | REVIEW_REQUESTED, REVIEW_STARTED, REVIEW_SUCCEEDED, REVIEW_FAILED | 언어·모델·실패 코드. 프롬프트·리뷰 내용 제외 |
+| 기록·내보내기 | RECORD_DELETED, PDF_GENERATED, PDF_EXPORTED | 직접 삭제, PDF 생성, 다운로드/Drive 및 성공 여부 |
+| 문의·운영 | INQUIRY_CREATED, INQUIRY_UPDATED, COSTS_IMPORTED, CONTENT_PUBLISHED, ADMIN_SECTION_VIEWED | 유형·상태·가져온 행 수·게시 개수·관리 메뉴. 문의 본문/답변 제외 |
+| UI 상태·오류 | THEME_CHANGED, CLIENT_ERROR, ERROR_RECOVERY | 테마·안전한 오류 코드·복구 동작. 예외 메시지/스택 제외 |
+
+이벤트 원장은 사용자 행동과 완료된 서버 작업을 구분한다. `PDF_GENERATED`는 서버가 PDF를 만들었다는 뜻이며 사용자의 다운로드 완료를 증명하지 않는다. `PDF_EXPORTED`는 브라우저가 내보내기 동작/Drive 요청을 성공 처리한 상태다. UI 이벤트는 비즈니스 결제·정답 판정의 근거로 사용하지 않는다. PG 결제가 비활성인 상태에서 구매·환불 이벤트를 가짜로 만들지 않는다. 향후 실제 PG 서명 검증 처리에 결제·환불 이벤트를 추가해야 한다. 자동 보관 만료·GC·heartbeat·조회 polling은 사용자 행동 이벤트로 집계하지 않는다.
+
+## 중복·실패·성능
+
+- 서버 이벤트는 고유 작업 키로 원장/전송 큐를 함께 중복 제거한다. UI는 UUID 요청 키를 재사용한다. 서버 이벤트 타입은 클라이언트가 제출할 수 없다.
+- UI는 1초 단위, 최대 25건씩 전송한다. 연결 실패 시 최대 30초 간격으로 재시도하고 메모리에 최대 200건만 둔다. 탭 강제 종료·오프라인 장기 지속·메모리 한도에서는 유실될 수 있다. 키 입력·프레임별로 전송하지 않는다.
+- 기존 1분 유지보수 스케줄에서 GA4 큐를 처리한다. 사용자/브라우저 컨텍스트별 최대 25건, 호출당 최대 250건 또는 20초 예산이다. 이벤트별 실제 발생 시각을 유지한다. 대량 이용 시 PENDING 추이와 처리 지연을 보고 처리량을 조정한다.
+- 전송 전 `/debug/mp/collect` 검증 후 `/mp/collect`로 보낸다. 검증 단계의 네트워크 장애는 최대 5회 재시도한다. 2xx는 `ACCEPTED`(HTTP 수신)이며 GA4 보고서 반영을 증명하지 않는다. 실제 수신 여부가 불명확한 요청/임대 만료는 `UNCERTAIN`으로 격리하고 자동 재전송하지 않는다. 검증 실패는 `REJECTED`, 72시간이 지난 미전송 이벤트는 `EXPIRED`다. 만료되어도 원장은 남는다.
+- GA4는 일반 이벤트 ID의 exactly-once 처리를 보장하지 않는다. 정확한 운영 집계는 DB 원장을 기준으로 한다. 활성화 전 72시간 이상 지난 데이터는 DB 원장에서 분석하며 시간을 조작해 GA4에 넣지 않는다.
+- 브라우저 GA 태그의 client_id/session_id와 임의 생성한 analytics 사용자 UUID만 전달한다. 브라우저 정보가 없는 이벤트의 client_id는 analytics UUID를 해시한 숫자.숫자 형식이며 실제 세션을 생성하거나 추정하지 않는다. 태그 차단·Android WebView 등 컨텍스트가 없으면 `server_unattributed`로 구분한다. 이를 정상 웹 세션·체류 시간 지표로 해석하지 않는다. engagement_time을 임의 생성하지 않는다.
+- Google 태그 자체의 page_view/first_visit/session_start 등 자동 이벤트는 GA4의 웹 계측이며 36종 앱 이벤트 원장과 별도다. 앱 이벤트를 Google 태그와 서버 양쪽에서 중복 전송하지 않는다.
+
+## GA4 활성화
+
+1. Algostep 전용 GA4 속성과 **웹** 데이터 스트림을 사용한다. 서비스 URL은 `https://algostep-90062080967.us-central1.run.app`이다. 속성 시간대는 Asia/Seoul, 통화는 KRW로 설정한다. 웹뷰는 같은 웹 이벤트 모델을 사용한다.
+2. 데이터 스트림에서 **향상된 측정은 끈다**. 검색어·폼·다운로드 URL 등 비허용 자동 수집을 방지하고 명시한 앱 이벤트만 사용한다. Google Signals/광고 개인 최적화는 사용하지 않는다.
+3. Measurement Protocol API Secret을 Secret Manager의 전용 Secret에 저장한다. 비밀 값은 채팅/Git/Terraform 변수 파일/로그에 넣지 않는다. `ga4_measurement_id`에 공개 측정 ID를, `secret_ids`의 `GA4_API_SECRET`에 **Secret 이름**만 추가한다. Cloud Run 서비스 계정은 그 Secret 읽기 권한만 갖는다.
+4. 배포 후 관리자 연결 상태와 큐 상태를 확인한다. 테스트 환경용 속성 또는 구분 가능한 한 번의 실제 학습 동작으로 검증 응답과 GA4 이벤트 보고서를 대조한다. HTTP 수신만으로 완료 처리하지 않는다.
+5. 보고서 필터가 필요하면 이벤트 범위 맞춤 측정기준 `screen`, `topic`, `problem`, `language`, `mode`, `verdict`, `event_source`, `destination`, `section`을 등록한다. `event_id`나 사용자 UUID는 고유값이 많으므로 맞춤 측정기준으로 등록하지 않는다. 기본 통계는 `as_*` 이벤트 이름으로 조회할 수 있다.
+
+코드는 로그인 사용자에 한해 태그를 로드하고 로그아웃하면 추적을 비활성화한다. 전송에 코드·이메일·Google sub·문의 본문·검색어·개별 기록 URL·인증 토큰을 포함하지 않는다. 브라우저 page URL은 서비스 origin으로 고정한다. API Secret은 서버에서만 사용한다.
+
+## Amplitude 활성화
+
+GA4와 같은 원장에서 HTTP V2 API로 전송한다. 별도 분석 서버나 DB 접속 계정은 필요하지 않다. 기존 SQL 뷰는 내부 운영 조회용으로 유지한다.
+
+1. Amplitude에 Algostep 전용 프로젝트를 생성하고 프로젝트의 이벤트 수집 API 키만 Secret Manager `algostep-amplitude-api-key`에 저장한다. 데이터 조회·관리용 Secret Key는 사용하지 않는다.
+2. Cloud Run의 `AMPLITUDE_API_KEY`에 Secret을 연결하고 프로젝트 리전에 따라 `AMPLITUDE_REGION=US` 또는 `EU`를 설정한다. `AMPLITUDE_PROJECT_URL`에는 자격 증명이 없는 프로젝트 URL만 지정한다.
+3. 마이그레이션 7은 별도 `amplitude_deliveries` 큐를 추가한다. 기존 원장과 GA4 큐는 유지한다. 키가 없으면 외부 전송하지 않는다.
+4. 최대 10건씩, 유지보수 호출당 최대 250건 또는 20초간 처리한다. 네트워크 실패·429·5xx·임대 만료는 재시도한다. 최초 전송 payload와 `device_id`/`insert_id`를 저장해 재시도마다 유지한다. 공급자의 7일 중복 제거 기간보다 짧은 6일 후 재시도를 종료한다.
+5. 유효한 200 응답은 `INGESTED`로 표시한다. 신규 수집 수가 요청 건수보다 작으면 `INGESTED_OR_DEDUPLICATED`로 구분한다. 이는 보고서 반영 완료를 뜻하지 않으므로 Amplitude 이벤트 화면에서 별도 확인한다. 4xx(429 제외)는 `REJECTED`로 표시한다.
+6. GA4와 Amplitude는 독립적으로 전송한다. 한 공급자의 실패가 다른 공급자의 큐 처리를 막지 않는다. 소스 코드·문의 내용·이메일·Google ID는 전달하지 않는다.
+
+가입·접속·학습·실행·리뷰 전환은 Amplitude에서 분석한다. 실제 비용·매출·문의 운영 지표는 기존 관리자 대시보드와 DB 원장을 기준으로 한다. 결제 연동 전 구매 이벤트를 만들지 않는다. 유료 플랜은 자동 구매하지 않는다.
+
+## API / 운영 확인
+
+- `POST /api/analytics/events`: 로그인·CSRF 필수, 배열 1~25건, 경로별 분당 60요청(기본 IP 제한). 허용 타입/필드 외 입력은 전체 배치를 거절한다.
+- `GET /api/admin/analytics`: 관리자 전용. 설정 여부, 전송 상태 건수, 안전한 Amplitude 프로젝트 주소, 이벤트 카탈로그. API Secret을 반환하지 않는다.
+- 현재 원장/전송 상태는 별도 자동 삭제 정책 없이 보관한다. Supabase 용량은 지속 관찰하고 보관 기간을 결정한 후 원장 아카이브/삭제를 적용해야 한다. 학습 기록 30일 보관 정책을 비즈니스 원장에 자동 적용하지 않는다.
+
+참고: [GA4 전송 및 제한](https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events), [GA4 검증](https://developers.google.com/analytics/devguides/collection/protocol/ga4/validating-events), [Amplitude HTTP V2](https://www.amplitude.com/docs/apis/analytics/http-v2).
+
+
+## 연결된 운영 대상
+
+- GA4: Algostep, 속성 `555063494`, 웹 스트림 `15808193214`, 측정 ID `G-JR60WCNBT4`. 향상된 측정은 꺼져 있다.
+- Amplitude: Algostep 프로젝트 `865456`, 미국 데이터 리전, Asia/Seoul, 주 시작 월요일, KRW. 서버 수집용 `algostep-server` API 키만 사용한다.
+- 두 키는 Secret Manager에 저장되며 `algostep-api` 서비스 계정에 각 Secret의 읽기 권한만 부여했다. 키 값은 Git 또는 클라이언트에 포함하지 않는다.
+- 2026-09-20: 사용자 승인 후 GA4 수집 확인을 완료했다. GA4 실시간 보고서와 Amplitude 라이브 이벤트에서 `as_screen_viewed`, `as_admin_section_viewed` 수신을 확인했다.
diff --git a/docs/implementation-status.md b/docs/implementation-status.md
index eb520df..f61d000 100644
--- a/docs/implementation-status.md
+++ b/docs/implementation-status.md
@@ -55,3 +55,7 @@
- 도메인·TLS·클라우드 계정. 이 저장소 생성으로 서비스가 인터넷에 배포되는 것은 아니다.
검증 증거와 최신 통과 여부는 GitHub Actions 및 `docs/verification.md`를 확인한다. 로컬 통합 테스트가 외부 인증·PG·프로덕션 VM 보안 검증을 대신하지 않는다.
+
+## 비즈니스 분석 확장 (2026-09-20)
+
+36종 이벤트 원장과 GA4·Amplitude 독립 전송 큐를 구현했다. 관리자 화면은 공급자별 설정·전송 상태를 표시한다. GA4와 Amplitude 전용 프로젝트의 키를 Secret Manager에 저장하고 서버에 연결했다. 마이그레이션 7과 Cloud Run 배포가 적용됐으며 두 공급자의 실제 이벤트 수신 화면에서 확인했다. 브라우저 컨텍스트 없는 GA4 식별자 형식 보완 및 미전송 이벤트 재처리를 진행했다. [연결 절차 및 한계](analytics-integrations.md)를 참고한다.
diff --git a/docs/verification.md b/docs/verification.md
index 43394bd..878de7e 100644
--- a/docs/verification.md
+++ b/docs/verification.md
@@ -98,3 +98,29 @@ GitHub 업로드와 CI 통과를 운영 출시로 간주하지 않는다. 최신
- 자격 증명 없는 임시 N2 빌더에서 수정한 게스트로 전체 47개 C++ 프로그램을 다시 실행했다. 139개 공개 테스트 전부 AC이며 각 테스트의 추적이 비어 있지 않았다. 이미지 `algostep-worker-trace-20260919` 생성 완료. 커널 SHA-256은 기존과 같고 새 rootfs는 `c18711868b52f50a473903569289d5343d597275ac34ae4e7f76064880dda3d5`다. 임시 빌더와 디스크는 정리했다.
- 사용자 명시 승인 후 `algostep-worker-1`·`algostep-worker-2`를 검증된 이미지로 교체했다. 실행 중 작업이 없는 상태에서 신규 배정을 차단하고 Scheduler를 일시 정지했다. 삭제 보호는 새 인스턴스에서 모두 `true`로 복원했고, 인스턴스별 기존 전원 관리 IAM도 다시 연결했다. Scheduler 재개 및 Terraform `No changes`를 확인했다.
- 새 워커 두 대의 자동 기동과 C++ 메모이제이션·SCC, Python·Java 재실행을 확인했다. 최종 96개 제출의 306개 테스트 모두 AC이며 306개 전부에 비어 있지 않은 추적이 존재한다. GCS 체크섬·복잡도 분석도 통과했다. 대형 C++ 추적은 안전한 크기/시간 한도에서 접두 단계와 잘림 표시를 제공한다.
+
+## GA4 · Redash 이벤트 확장 (2026-09-20)
+
+- 타입 검사·전체 빌드 성공. 단위/API 49개 통과, 실제 PostgreSQL 전용 1개는 로컬 환경 부재로 생략(CI 실행).
+- 새 분석 테스트 8개: 원장/outbox 원자성·중복 제거, 클라이언트 위조/비허용 필드/CSRF 차단, 개인정보 제외, 비동기 실행 컨텍스트, 25건 배치와 시각 보존, 검증 거절, 불명확한 전송 격리, 백오프/72시간 만료, 조회 뷰 및 13개 SQL 실행.
+- 브라우저 16개 시나리오 통과. 전송 503 후 동일 키 재시도, 실제 UI 이벤트 수집 및 관리자 연결 상태 표시를 확인했다. 상태 화면 스크린샷도 검토했다.
+- KST 자정 경계의 기존 날짜→타임스탬프 암묵 변환 오류를 수정했다. 자정 시작 포함·전날 마지막 초 및 다음 날 시작 제외 회귀 검사 통과.
+- GA4/Redash 실제 계정 활성화와 외부 보고서 수신은 필요한 연결 정보가 없어 검증 전이다. 테스트 공급자 응답을 실제 GA4 연결 성공으로 표시하지 않는다.
+
+- Cloud Build `476c6857-af98-4b4a-8e7c-5d993f405d95` 성공 후 Cloud Run 1개 리소스만 갱신했다. 운영 DB 마이그레이션 6, 헬스 200/익명 관리자 접근 401, 운영 지표 200을 확인했다. 실제 로그인 브라우저에서 SCREEN_VIEWED/ADMIN_SECTION_VIEWED 저장 및 GA4·Redash 연결 설정 대기 표시를 확인했다. 워커·IAM·유료 Redash 호스트는 변경/추가하지 않았다.
+
+
+## GA4 · Amplitude 전환 준비 (2026-09-20)
+
+- Redash 설치·DB 계정 생성 경로를 제거하고 Amplitude HTTP V2 전송 큐와 마이그레이션 7을 추가했다. 기존 마이그레이션 6과 SQL 조회 뷰는 유지한다.
+- 타입 검사와 전체 회귀 테스트 54건 통과, 외부 PostgreSQL 환경 전용 1건은 로컬에서 건너뛰었다. Amplitude 배치 제한·재시도 payload 유지·원장 중복 방지·롤백·리전·재시도 종료를 검증했다.
+- GA4 Algostep 계정/속성과 웹 스트림 생성: 측정 ID G-JR60WCNBT4. 사용자 데이터 수집 확인 및 GA4·Amplitude 키 생성/저장 승인은 대기 중이다. 실제 공급자 수집이나 배포 완료로 표시하지 않는다.
+
+
+## GA4 · Amplitude 운영 연결 완료 (2026-09-20)
+
+- 사용자 승인 후 GA4 데이터 수집 확인과 서버 API Secret 생성, Amplitude Algostep 프로젝트(865456)의 서버 수집 API 키 생성을 완료했다. 두 키는 GCP Secret Manager에 저장하고 `algostep-api` 계정에 해당 Secret 읽기만 허용했다.
+- Cloud Build `804b3647-15f5-42a0-abb1-d119d01932c2`로 마이그레이션 7과 두 공급자 연결을 배포했다. GA4 실시간 보고서와 Amplitude 라이브 이벤트에서 화면 조회·관리 메뉴 이벤트 수신을 확인했다.
+- GA4 실제 검증에서 브라우저 컨텍스트가 없는 기존 이벤트 4건의 client_id 형식 오류를 발견했다. opaque analytics UUID를 해시한 숫자.숫자 형식으로 수정했으며 session_id/engagement_time을 임의 생성하지 않는다. 회귀 테스트 9건 및 타입 검사가 통과했고, 실제 GA4 검증 API의 오류가 없어졌다.
+- 최종 Cloud Build `6c32d5be-84b5-434c-906e-4887bc01cfaa` 성공 후 Cloud Run 이미지 1개만 갱신했다. 검증에서 거절돼 collect를 호출하지 않았던 4건만 개별 재검증 후 재처리했다. 최종 관리자 화면은 GA4 `HTTP_ACCEPTED` 8건, Amplitude `INGESTED` 8건, 거절/대기 0건이었다. 이 수치는 검증 시점의 값이다.
+- 서비스 health/ready/maintenance 200, 익명 세션 및 운영 API 접근 401, 마이그레이션 1~7 적용을 확인했다. 이미 전송된 이벤트를 재전송하거나 이벤트 발생 시각을 변경하지 않았다.
diff --git a/infra/gcp/main.tf b/infra/gcp/main.tf
index dcf5fb2..1dc3d50 100644
--- a/infra/gcp/main.tf
+++ b/infra/gcp/main.tf
@@ -52,9 +52,33 @@ variable "admin_emails" {
default = []
}
+variable "ga4_measurement_id" {
+ type = string
+ default = ""
+ validation {
+ condition = var.ga4_measurement_id == "" || can(regex("^G-[A-Z0-9]{4,20}$", var.ga4_measurement_id))
+ error_message = "Use a GA4 web measurement ID, or leave empty to disable delivery."
+ }
+}
+
+variable "amplitude_project_url" {
+ type = string
+ default = ""
+ description = "Amplitude project URL without credentials."
+}
+
+variable "amplitude_region" {
+ type = string
+ default = "US"
+ validation {
+ condition = contains(["US", "EU"], var.amplitude_region)
+ error_message = "Amplitude region must be US or EU."
+ }
+}
+
variable "secret_ids" {
- description = "Existing Secret Manager secret names keyed by DATABASE_URL, SESSION_SECRET, RUNNER_TOKEN, OPERATIONS_TOKEN; optionally GEMINI_API_KEY. Never pass secret values."
+ description = "Existing Secret Manager secret names keyed by DATABASE_URL, SESSION_SECRET, RUNNER_TOKEN, OPERATIONS_TOKEN; optionally GEMINI_API_KEY, GA4_API_SECRET and AMPLITUDE_API_KEY. Never pass secret values."
type = map(string)
validation {
condition = alltrue([for k in ["DATABASE_URL", "SESSION_SECRET", "RUNNER_TOKEN", "OPERATIONS_TOKEN"] : contains(keys(var.secret_ids), k)])
@@ -157,8 +181,11 @@ resource "google_cloud_run_v2_service" "api" {
dynamic "env" {
for_each = merge({
- ADMIN_EMAILS = join(",", var.admin_emails),
- WEB_ORIGIN = var.web_origin, GOOGLE_CLIENT_ID = var.google_client_id, TRACE_BUCKET = google_storage_bucket.traces.name, BACKGROUND_WORKER = "false", SCHEDULER_AUDIENCE = var.web_origin, SCHEDULER_EMAIL = google_service_account.scheduler.email, LLM_PROVIDER = "gemini", GEMINI_MODEL = "gemini-3.8-flash", GEMINI_BACKEND = "vertex-express", DATABASE_SCHEMA = "algostep", DATABASE_SSL_CA = "/app/certs/supabase-ca.crt", GCP_PROJECT = var.project_id, WORKER_AUTOSCALE = tostring(var.enable_workers), WORKER_ZONE = var.worker_zone, WORKER_NAMES = "algostep-worker-1,algostep-worker-2", WORKER_IDLE_SECONDS = "900", RUNNER_SLOTS_PER_HOST = "2"
+ ADMIN_EMAILS = join(",", var.admin_emails),
+ GA4_MEASUREMENT_ID = var.ga4_measurement_id,
+ AMPLITUDE_PROJECT_URL = var.amplitude_project_url,
+ AMPLITUDE_REGION = var.amplitude_region,
+ WEB_ORIGIN = var.web_origin, GOOGLE_CLIENT_ID = var.google_client_id, TRACE_BUCKET = google_storage_bucket.traces.name, BACKGROUND_WORKER = "false", SCHEDULER_AUDIENCE = var.web_origin, SCHEDULER_EMAIL = google_service_account.scheduler.email, LLM_PROVIDER = "gemini", GEMINI_MODEL = "gemini-3.8-flash", GEMINI_BACKEND = "vertex-express", DATABASE_SCHEMA = "algostep", DATABASE_SSL_CA = "/app/certs/supabase-ca.crt", GCP_PROJECT = var.project_id, WORKER_AUTOSCALE = tostring(var.enable_workers), WORKER_ZONE = var.worker_zone, WORKER_NAMES = "algostep-worker-1,algostep-worker-2", WORKER_IDLE_SECONDS = "900", RUNNER_SLOTS_PER_HOST = "2"
}
)
content {
diff --git a/scripts/business-queries.json b/scripts/business-queries.json
new file mode 100644
index 0000000..e5ef7f9
--- /dev/null
+++ b/scripts/business-queries.json
@@ -0,0 +1,54 @@
+[
+ {
+ "name": "누적·일·주·월 접속자",
+ "query": "SELECT count(DISTINCT user_id) AS cumulative_visitors,count(DISTINCT user_id) FILTER(WHERE day_kst=(now() AT TIME ZONE 'Asia/Seoul')::date) AS daily_visitors,count(DISTINCT user_id) FILTER(WHERE day_kst>=date_trunc('week',now() AT TIME ZONE 'Asia/Seoul')::date) AS weekly_visitors,count(DISTINCT user_id) FILTER(WHERE day_kst>=date_trunc('month',now() AT TIME ZONE 'Asia/Seoul')::date) AS monthly_visitors FROM algostep.analytics_visitors"
+ },
+ {
+ "name": "일별 접속자",
+ "query": "SELECT day_kst,count(DISTINCT user_id) AS visitors FROM algostep.analytics_visitors WHERE day_kst >= (now() AT TIME ZONE 'Asia/Seoul')::date-89 GROUP BY day_kst ORDER BY day_kst"
+ },
+ {
+ "name": "주별 접속자",
+ "query": "SELECT date_trunc('week',day_kst)::date AS week_kst,count(DISTINCT user_id) AS visitors FROM algostep.analytics_visitors WHERE day_kst >= date_trunc('week',now() AT TIME ZONE 'Asia/Seoul')::date-interval '11 weeks' GROUP BY 1 ORDER BY 1"
+ },
+ {
+ "name": "월별 접속자",
+ "query": "SELECT date_trunc('month',day_kst)::date AS month_kst,count(DISTINCT user_id) AS visitors FROM algostep.analytics_visitors WHERE day_kst >= date_trunc('month',now() AT TIME ZONE 'Asia/Seoul')::date-interval '11 months' GROUP BY 1 ORDER BY 1"
+ },
+ {
+ "name": "가입 사용자",
+ "query": "SELECT count(*) AS total_users,count(*) FILTER(WHERE registered_at_kst >= date_trunc('month',now() AT TIME ZONE 'Asia/Seoul')) AS new_users_this_month FROM algostep.analytics_users"
+ },
+ {
+ "name": "전체 비즈니스 이벤트",
+ "query": "SELECT event_type,ga4_event_name,count(*) AS events,count(DISTINCT user_id) AS users FROM algostep.analytics_events WHERE occurred_at_kst >= (now() AT TIME ZONE 'Asia/Seoul')-interval '30 days' GROUP BY 1,2 ORDER BY events DESC"
+ },
+ {
+ "name": "학습 활동별 고유 사용자",
+ "query": "SELECT event_type,count(DISTINCT user_id) AS users,count(*) AS events FROM algostep.analytics_events WHERE event_type IN ('SCREEN_VIEWED','QUIZ_ANSWERED','PRACTICE_STARTED','EXECUTION_REQUESTED','EXECUTION_FINISHED','REVIEW_REQUESTED','REVIEW_SUCCEEDED') AND occurred_at_kst >= (now() AT TIME ZONE 'Asia/Seoul')-interval '30 days' GROUP BY 1 ORDER BY 1"
+ },
+ {
+ "name": "실행 결과",
+ "query": "SELECT metadata->>'verdict' AS verdict,count(*) AS executions FROM algostep.analytics_events WHERE event_type='EXECUTION_FINISHED' AND occurred_at_kst >= (now() AT TIME ZONE 'Asia/Seoul')-interval '30 days' GROUP BY 1 ORDER BY 2 DESC"
+ },
+ {
+ "name": "수집된 실제 비용",
+ "query": "SELECT day_kst,service,currency,amount FROM algostep.analytics_costs WHERE day_kst >= (now() AT TIME ZONE 'Asia/Seoul')::date-89 ORDER BY day_kst,service,currency"
+ },
+ {
+ "name": "매출과 환불",
+ "query": "SELECT day_kst,currency,sum(CASE WHEN kind='PAYMENT' THEN amount ELSE 0 END) AS gross,sum(CASE WHEN kind='REFUND' THEN amount ELSE 0 END) AS refunds,sum(CASE WHEN kind='PAYMENT' THEN amount ELSE -amount END) AS net FROM algostep.analytics_revenue WHERE day_kst >= (now() AT TIME ZONE 'Asia/Seoul')::date-89 GROUP BY 1,2 ORDER BY 1,2"
+ },
+ {
+ "name": "문의 처리 현황",
+ "query": "SELECT category,status,sum(inquiries) AS inquiries FROM algostep.analytics_inquiries GROUP BY 1,2 ORDER BY 1,2"
+ },
+ {
+ "name": "GA4 전송 상태",
+ "query": "SELECT * FROM algostep.analytics_delivery_status ORDER BY status,event_type"
+ },
+ {
+ "name": "최근 이벤트 상세",
+ "query": "SELECT event_id,event_type,user_id,occurred_at_kst,metadata FROM algostep.analytics_events ORDER BY created_at DESC,event_id LIMIT 2000"
+ }
+]
diff --git a/tests/browser/analytics.spec.ts b/tests/browser/analytics.spec.ts
new file mode 100644
index 0000000..183d1ca
--- /dev/null
+++ b/tests/browser/analytics.spec.ts
@@ -0,0 +1,63 @@
+import { test, expect } from "@playwright/test";
+test("UI events survive a failed batch without duplicates or typed text; admin shows delivery status", async ({
+ page,
+}) => {
+ const auth = await page.request.post("/api/auth/google", {
+ headers: { origin: "http://127.0.0.1:5173" },
+ data: { credential: "integration-admin-token" },
+ });
+ expect(auth.status()).toBe(200);
+ const batches: any[][] = [];
+ let failed = false;
+ await page.route("**/api/analytics/events", async (route) => {
+ batches.push(route.request().postDataJSON().events);
+ if (!failed) {
+ failed = true;
+ await route.fulfill({
+ status: 503,
+ json: { message: "Temporary failure" },
+ });
+ } else await route.continue();
+ });
+ await page.goto("/");
+ await expect(
+ page.getByRole("navigation", { name: "학습 종류" }),
+ ).toBeVisible();
+ await page
+ .getByRole("navigation", { name: "학습 종류" })
+ .getByRole("link")
+ .first()
+ .click();
+ await page.getByRole("button", { name: "테마 변경" }).click();
+ await expect.poll(() => batches.length).toBeGreaterThanOrEqual(2);
+ expect(batches[1][0].requestKey).toBe(batches[0][0].requestKey);
+ await page
+ .locator("header nav")
+ .getByRole("button", { name: "관리자", exact: true })
+ .click();
+ await page
+ .getByRole("button", { name: "비즈니스 로그", exact: true })
+ .click();
+ await expect(
+ page.getByRole("heading", { name: "GA4 · Amplitude 연결 상태" }),
+ ).toBeVisible();
+ await expect(
+ page.getByText("GA4: 연결 설정 대기", { exact: true }),
+ ).toBeVisible();
+ await expect
+ .poll(async () => {
+ const r = await page.request.get("/api/admin/events?type=THEME_CHANGED");
+ return r.json().then((d) => d.rows.length);
+ })
+ .toBeGreaterThan(0);
+ const flattened = batches.flat();
+ expect(flattened.some((e) => e.type === "SCREEN_VIEWED")).toBe(true);
+ expect(flattened.some((e) => e.type === "CATEGORY_SELECTED")).toBe(true);
+ expect(JSON.stringify(flattened)).not.toMatch(
+ /example\.test|credential|source_code|access_token/,
+ );
+ await page.screenshot({
+ path: "test-results/analytics-status.png",
+ fullPage: true,
+ });
+});