From 087e2fc6d81bc9df629240b9522833f8133036d5 Mon Sep 17 00:00:00 2001
From: Sadturtleman
Date: Sun, 20 Sep 2026 00:12:39 +0900
Subject: [PATCH 1/5] Connect business event ledger to GA4 delivery and Redash
views
---
.env.example | 5 +
apps/server/src/admin.ts | 14 +-
apps/server/src/analytics-context.ts | 19 ++
apps/server/src/app.ts | 96 ++++++--
apps/server/src/business.ts | 32 ++-
apps/server/src/client-events.ts | 70 ++++++
apps/server/src/content-admin.ts | 10 +
apps/server/src/event-catalog.ts | 125 ++++++++++
apps/server/src/ga4.ts | 215 ++++++++++++++++++
apps/server/src/maintenance.ts | 2 +
apps/server/src/migrations.ts | 19 ++
apps/server/src/review-worker.ts | 7 +
apps/server/test/admin.test.ts | 6 +-
apps/server/test/analytics.test.ts | 327 +++++++++++++++++++++++++++
apps/server/test/expansion.test.ts | 2 +-
apps/web/src/AdminDashboard.tsx | 71 ++++++
apps/web/src/AdvancedLesson.tsx | 25 +-
apps/web/src/DiagramCanvas.tsx | 17 +-
apps/web/src/QuizPanel.tsx | 7 +-
apps/web/src/analytics-context.ts | 9 +
apps/web/src/analytics.ts | 151 +++++++++++++
apps/web/src/api.ts | 2 +
apps/web/src/main.tsx | 98 +++++++-
apps/web/src/visualization.tsx | 40 +++-
docs/admin-dashboard.md | 4 +
docs/analytics-integrations.md | 68 ++++++
docs/implementation-status.md | 4 +
docs/verification.md | 8 +
infra/gcp/main.tf | 23 +-
scripts/bootstrap-redash.mjs | 164 ++++++++++++++
scripts/provision-redash-role.ts | 96 ++++++++
scripts/redash-queries.json | 54 +++++
tests/browser/analytics.spec.ts | 63 ++++++
33 files changed, 1799 insertions(+), 54 deletions(-)
create mode 100644 apps/server/src/analytics-context.ts
create mode 100644 apps/server/src/client-events.ts
create mode 100644 apps/server/src/event-catalog.ts
create mode 100644 apps/server/src/ga4.ts
create mode 100644 apps/server/test/analytics.test.ts
create mode 100644 apps/web/src/analytics-context.ts
create mode 100644 apps/web/src/analytics.ts
create mode 100644 docs/analytics-integrations.md
create mode 100644 scripts/bootstrap-redash.mjs
create mode 100644 scripts/provision-redash-role.ts
create mode 100644 scripts/redash-queries.json
create mode 100644 tests/browser/analytics.spec.ts
diff --git a/.env.example b/.env.example
index e7d185c..84681f4 100644
--- a/.env.example
+++ b/.env.example
@@ -9,6 +9,11 @@ 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=
+# Redash reads restricted SQL views. Never use the application owner DB password.
+REDASH_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/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..473d35f 100644
--- a/apps/server/src/business.ts
+++ b/apps/server/src/business.ts
@@ -1,25 +1,47 @@
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)
+ INSERT INTO analytics_deliveries(event_id) SELECT id FROM inserted`,
+ [
+ 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
+ ) INSERT INTO analytics_deliveries(event_id) SELECT id FROM e`,
+ [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..f77b890
--- /dev/null
+++ b/apps/server/src/ga4.ts
@@ -0,0 +1,215 @@
+import type { DB } from "./db.js";
+import { 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 ?? {};
+ 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 ?? "server." + (row.analytics_id ?? "algostep-system"),
+ ...(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. Redash reads 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();
+ let redashUrl: string | null = null;
+ try {
+ const u = new URL(process.env.REDASH_URL ?? "");
+ if (
+ u.protocol === "https:" &&
+ !u.username &&
+ !u.password &&
+ !u.search &&
+ !u.hash
+ )
+ redashUrl = u.href;
+ } catch {}
+ 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,
+ },
+ redash: { url: redashUrl, source: "read-only PostgreSQL analytics views" },
+ 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..26cb73e 100644
--- a/apps/server/src/maintenance.ts
+++ b/apps/server/src/maintenance.ts
@@ -4,6 +4,7 @@ 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";
export async function maintenance(db: DB, storage?: ObjectStorage) {
await cleanExpired(db);
await recoverReviews(db);
@@ -22,5 +23,6 @@ export async function maintenance(db: DB, storage?: ObjectStorage) {
}
const llm = llmOptions();
if (llm) await processReview(db, llm);
+ await deliverGa4(db);
if (fleetError) throw fleetError;
}
diff --git a/apps/server/src/migrations.ts b/apps/server/src/migrations.ts
index 284a861..1d08a1d 100644
--- a/apps/server/src/migrations.ts
+++ b/apps/server/src/migrations.ts
@@ -46,6 +46,25 @@ 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;
+ `,
+ },
];
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/analytics.test.ts b/apps/server/test/analytics.test.ts
new file mode 100644
index 0000000..4d7bfd7
--- /dev/null
+++ b/apps/server/test/analytics.test.ts
@@ -0,0 +1,327 @@
+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("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("Redash 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/redash-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..6983fff 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,
+ 6,
);
} finally {
await db.close();
diff --git a/apps/web/src/AdminDashboard.tsx b/apps/web/src/AdminDashboard.tsx
index ffce7c2..2c4a9a7 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 (
<>
+
이벤트 종류
setType(e.target.value)}>
@@ -467,6 +472,72 @@ function EventList({ query, events }: { query: string; events: any[] }) {
>
);
}
+function AnalyticsConnections() {
+ const [data, setData] = useState(null),
+ [error, setError] = useState("");
+ useEffect(() => {
+ let active = true;
+ api("/admin/analytics")
+ .then((d) => {
+ if (active) setData(d);
+ })
+ .catch((e) => {
+ if (active) setError(e.message);
+ });
+ return () => {
+ active = false;
+ };
+ }, []);
+ const labels: Record = {
+ PENDING: "대기",
+ PROCESSING: "전송 중",
+ ACCEPTED: "HTTP 수신 확인",
+ REJECTED: "거절",
+ UNCERTAIN: "수신 여부 불명",
+ EXPIRED: "전송 기한 만료",
+ };
+ return (
+
+ GA4 · Redash 연결 상태
+ {error && {error}
}
+ {!data && !error && 연결 상태를 불러오는 중…
}
+ {data && (
+ <>
+
+ GA4:{" "}
+ {data.ga4.configured
+ ? `전송 설정 완료 · ${data.ga4.measurementId}`
+ : "연결 설정 대기"}
+
+
+ Redash:{" "}
+ {data.redash.url ? (
+
+ 분석 대시보드 열기
+
+ ) : (
+ "연결 설정 대기"
+ )}
+
+ [
+ labels[r.status] ?? r.status,
+ number(r.count),
+ r.last_code ?? "—",
+ ])}
+ />
+
+ 현재 {data.catalog.length}종 이벤트를 수집합니다. GA4의 HTTP 수신
+ 확인은 보고서 반영 완료를 뜻하지 않습니다. 수신 여부가 불명확한 건은
+ 중복 집계를 막기 위해 자동 재전송하지 않으며, 원본 이벤트는
+ 데이터베이스에 남습니다.
+
+ >
+ )}
+
+ );
+}
function Finance({
data,
query,
diff --git a/apps/web/src/AdvancedLesson.tsx b/apps/web/src/AdvancedLesson.tsx
index 49c0c15..f6c288f 100644
--- a/apps/web/src/AdvancedLesson.tsx
+++ b/apps/web/src/AdvancedLesson.tsx
@@ -1,3 +1,4 @@
+import { track } from "./analytics.js";
import React, { useMemo, useState } from "react";
import { advancedLesson } from "./advanced-lessons.js";
import { DiagramCanvas } from "./DiagramCanvas.js";
@@ -25,6 +26,10 @@ export function AdvancedLesson({ topic }: { topic: string }) {
aria-label="예제 선택"
value={variant}
onChange={(e) => {
+ track("EXAMPLE_SELECTED", {
+ topic,
+ index: Number(e.target.value),
+ });
setVariant(Number(e.target.value));
setStep(0);
}}
@@ -72,14 +77,20 @@ export function AdvancedLesson({ topic }: { topic: string }) {
setStep(0)}
+ onClick={() => {
+ track("VISUALIZATION_CONTROL", { action: "first", step });
+ setStep(0);
+ }}
>
처음
setStep((s) => s - 1)}
+ onClick={() => {
+ track("VISUALIZATION_CONTROL", { action: "previous", step });
+ setStep((s) => s - 1);
+ }}
>
이전
@@ -88,14 +99,20 @@ export function AdvancedLesson({ topic }: { topic: string }) {
setStep((s) => s + 1)}
+ onClick={() => {
+ track("VISUALIZATION_CONTROL", { action: "next", step });
+ setStep((s) => s + 1);
+ }}
>
다음 단계
setStep(lesson.frames.length - 1)}
+ onClick={() => {
+ track("VISUALIZATION_CONTROL", { action: "last", step });
+ setStep(lesson.frames.length - 1);
+ }}
>
마지막
diff --git a/apps/web/src/DiagramCanvas.tsx b/apps/web/src/DiagramCanvas.tsx
index 3ccfdf3..8afca4f 100644
--- a/apps/web/src/DiagramCanvas.tsx
+++ b/apps/web/src/DiagramCanvas.tsx
@@ -1,3 +1,4 @@
+import { track } from "./analytics.js";
import React, { useEffect, useId, useRef, useState } from "react";
export type DiagramNode = {
id: string;
@@ -135,6 +136,7 @@ export function DiagramCanvas({
aria-label={`${label} 배치`}
value={layout}
onChange={(e) => {
+ track("VISUALIZATION_CONTROL", { action: "layout" });
setLayout(e.target.value);
setMoved({});
}}
@@ -147,6 +149,7 @@ export function DiagramCanvas({
className="secondary"
onClick={() => {
setMoved({});
+ track("VISUALIZATION_CONTROL", { action: "reset" });
setZoom(1);
setLayout("auto");
}}
@@ -155,7 +158,8 @@ export function DiagramCanvas({
+ onClick={() => {
+ track("VISUALIZATION_CONTROL", { action: "fit" });
setZoom(
Math.max(
0.3,
@@ -165,8 +169,8 @@ export function DiagramCanvas({
500 / height,
),
),
- )
- }
+ );
+ }}
>
화면에 맞춤
@@ -174,6 +178,10 @@ export function DiagramCanvas({
확대{" "}
+ track("VISUALIZATION_CONTROL", { action: "seek" })
+ }
+ onKeyUp={() => track("VISUALIZATION_CONTROL", { action: "seek" })}
type="range"
min="0.3"
max="1.8"
@@ -307,6 +315,8 @@ export function DiagramCanvas({
}));
}}
onPointerUp={() => {
+ if (drag.current)
+ track("VISUALIZATION_CONTROL", { action: "move_node" });
drag.current = null;
}}
onPointerCancel={() => {
@@ -320,6 +330,7 @@ export function DiagramCanvas({
ArrowDown: [0, 12],
};
if (!delta[e.key]) return;
+ track("VISUALIZATION_CONTROL", { action: "move_node" });
e.preventDefault();
setMoved((prev) => ({
...prev,
diff --git a/apps/web/src/QuizPanel.tsx b/apps/web/src/QuizPanel.tsx
index b3bb083..4d9d671 100644
--- a/apps/web/src/QuizPanel.tsx
+++ b/apps/web/src/QuizPanel.tsx
@@ -1,4 +1,5 @@
-import React, { useState } from "react";
+import { track } from "./analytics.js";
+import React, { useState, useEffect } from "react";
import { post } from "./api.js";
export function QuizPanel({
topic,
@@ -14,6 +15,9 @@ export function QuizPanel({
[busy, setBusy] = useState(false);
const [results, setResults] = useState>({});
const q = questions[index];
+ useEffect(() => {
+ track("QUIZ_VIEWED", { topic: topic.id, index });
+ }, [topic.id, index]);
const move = (next: number) => {
setIndex(next);
setAnswer(null);
@@ -68,6 +72,7 @@ export function QuizPanel({
{
+ track("QUIZ_RETRIED", { topic: topic.id });
setFeedback(null);
setAnswer(null);
}}
diff --git a/apps/web/src/analytics-context.ts b/apps/web/src/analytics-context.ts
new file mode 100644
index 0000000..3d4a902
--- /dev/null
+++ b/apps/web/src/analytics-context.ts
@@ -0,0 +1,9 @@
+let context: Record = {};
+export const analyticsHeaders = () => context;
+export const setAnalyticsContext = (clientId?: string, sessionId?: number) => {
+ context = {};
+ if (clientId && /^\d{1,20}\.\d{1,20}$/.test(clientId))
+ context["X-Analytics-Client-Id"] = clientId;
+ if (sessionId && Number.isSafeInteger(sessionId) && sessionId > 0)
+ context["X-Analytics-Session-Id"] = String(sessionId);
+};
diff --git a/apps/web/src/analytics.ts b/apps/web/src/analytics.ts
new file mode 100644
index 0000000..620ce9e
--- /dev/null
+++ b/apps/web/src/analytics.ts
@@ -0,0 +1,151 @@
+import { api } from "./api.js";
+import { setAnalyticsContext } from "./analytics-context.js";
+import type { clientEventTypes } from "../../server/src/event-catalog.js";
+type Event = {
+ requestKey: string;
+ type: (typeof clientEventTypes)[number];
+ properties: Record;
+};
+let owner: string | null = null;
+let queue: Event[] = [];
+let timer: ReturnType | undefined;
+let sending = false;
+let failures = 0;
+let generation = 0;
+let initializedTag: string | null = null;
+let contextUpdated = 0;
+export function setAnalyticsUser(userId: string | null) {
+ if (owner === userId) return;
+ owner = userId;
+ queue = [];
+ generation++;
+ failures = 0;
+ contextUpdated = 0;
+ setAnalyticsContext();
+ const w = window as any;
+ if (initializedTag) {
+ w["ga-disable-" + initializedTag] = !userId;
+ if (!userId) w.gtag?.("set", { user_id: null });
+ }
+}
+export function track(
+ type: Event["type"],
+ properties: Event["properties"] = {},
+) {
+ if (!owner) return;
+ // Bounded memory only. No code or identity details are persisted in storage.
+ if (queue.length >= 200) queue.shift();
+ queue.push({ requestKey: crypto.randomUUID(), type, properties });
+ schedule();
+}
+function schedule() {
+ if (timer || !queue.length) return;
+ timer = setTimeout(
+ () => {
+ timer = undefined;
+ void flushAnalytics();
+ },
+ Math.min(30000, 1000 * 2 ** failures),
+ );
+}
+export async function flushAnalytics() {
+ if (sending || !owner || !queue.length) return;
+ sending = true;
+ const current = generation,
+ batch = queue.slice(0, 25);
+ try {
+ if (initializedTag && Date.now() - contextUpdated > 60000)
+ await refreshTagContext(initializedTag);
+ if (current !== generation) return;
+ await api("/analytics/events", {
+ method: "POST",
+ body: JSON.stringify({ events: batch }),
+ keepalive: true,
+ });
+ if (current === generation) {
+ queue.splice(0, batch.length);
+ failures = 0;
+ }
+ } catch (e: any) {
+ if (current === generation) {
+ if (e.status === 400 || e.status === 403) {
+ queue.splice(0, batch.length);
+ } else if (e.status === 401) {
+ queue = [];
+ } else failures = Math.min(5, failures + 1);
+ }
+ } finally {
+ sending = false;
+ schedule();
+ }
+}
+if (typeof window !== "undefined") {
+ addEventListener("pagehide", () => void flushAnalytics());
+ addEventListener("online", () => void flushAnalytics());
+}
+
+export async function initializeGoogleTag(
+ measurementId: string | null,
+ userId: string,
+) {
+ if (!measurementId || !/^G-[A-Z0-9]{4,20}$/.test(measurementId)) return;
+ const w = window as any;
+ if (initializedTag && initializedTag !== measurementId) return;
+ if (!initializedTag) {
+ initializedTag = measurementId;
+ w.dataLayer = w.dataLayer ?? [];
+ w.gtag = function () {
+ w.dataLayer.push(arguments);
+ };
+ w.gtag("consent", "default", {
+ ad_storage: "denied",
+ ad_user_data: "denied",
+ ad_personalization: "denied",
+ analytics_storage: "granted",
+ });
+ w.gtag("js", new Date());
+ // Only the stable application origin is exposed, never record URLs, queries,
+ // referrers, typed inputs or user data. Domain events go through the server.
+ w.gtag("config", measurementId, {
+ send_page_view: false,
+ allow_google_signals: false,
+ allow_ad_personalization_signals: false,
+ page_location: location.origin + "/",
+ page_referrer: "",
+ user_id: userId,
+ });
+ const script = document.createElement("script");
+ script.async = true;
+ script.referrerPolicy = "origin";
+ script.src = "https://www.googletagmanager.com/gtag/js?id=" + measurementId;
+ document.head.append(script);
+ w.gtag("event", "page_view", {
+ page_title: "Algostep",
+ page_location: location.origin + "/",
+ page_referrer: "",
+ });
+ } else {
+ w.gtag("set", { user_id: userId });
+ }
+ await refreshTagContext(measurementId);
+}
+async function refreshTagContext(measurementId: string) {
+ const w = window as any;
+ const get = (field: string) =>
+ new Promise((resolve) => {
+ const timeout = setTimeout(() => resolve(undefined), 3000);
+ w.gtag("get", measurementId, field, (v: any) => {
+ clearTimeout(timeout);
+ resolve(v);
+ });
+ });
+ const current = generation;
+ const [client, session] = await Promise.all([
+ get("client_id"),
+ get("session_id"),
+ ]);
+ if (current === generation) {
+ setAnalyticsContext(client, Number(session));
+ contextUpdated = Date.now();
+ }
+}
diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts
index 4fae443..7359066 100644
--- a/apps/web/src/api.ts
+++ b/apps/web/src/api.ts
@@ -1,3 +1,4 @@
+import { analyticsHeaders } from "./analytics-context.js";
let csrf = "";
export const setCsrf = (value: string) => {
csrf = value;
@@ -23,6 +24,7 @@ export async function api(
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrf,
+ ...analyticsHeaders(),
...options.headers,
},
signal: options.signal ?? AbortSignal.timeout(20000),
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index fa7edcf..c7feaef 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -1,3 +1,9 @@
+import {
+ track,
+ setAnalyticsUser,
+ initializeGoogleTag,
+ flushAnalytics,
+} from "./analytics.js";
import { QuizPanel } from "./QuizPanel.js";
import { AdminDashboard } from "./AdminDashboard.js";
import { SupportPanel } from "./SupportPanel.js";
@@ -74,6 +80,49 @@ function App() {
runKey = useRef(null),
reviewKey = useRef(null);
const shownExecutionErrors = useRef(new Set());
+ useEffect(() => {
+ setAnalyticsUser(user?.id ?? null);
+ if (user?.analyticsId)
+ void initializeGoogleTag(
+ config?.ga4MeasurementId ?? null,
+ user.analyticsId,
+ );
+ }, [user?.id, user?.analyticsId, config?.ga4MeasurementId]);
+ useEffect(() => {
+ if (!user) return;
+ const screen =
+ mobile &&
+ ["workspace", "problems", "history", "challenges"].includes(page)
+ ? "home"
+ : page;
+ track("SCREEN_VIEWED", {
+ screen,
+ ...(["lesson", "practice", "challenges"].includes(screen) && topic?.id
+ ? { topic: topic.id }
+ : {}),
+ });
+ }, [user?.id, page, topic?.id, mobile]);
+ useEffect(() => {
+ if (!user || !filter.trim()) return;
+ const timer = setTimeout(
+ () =>
+ track("SEARCH_USED", {
+ result_count: topics.filter((t) => t.title.includes(filter.trim()))
+ .length,
+ }),
+ 600,
+ );
+ return () => clearTimeout(timer);
+ }, [user?.id, filter, topics]);
+ useEffect(() => {
+ if (user && error)
+ track("CLIENT_ERROR", {
+ code: /^[A-Z0-9_]{1,80}$/.test(error.code)
+ ? error.code
+ : "UNKNOWN_ERROR",
+ });
+ }, [user?.id, error]);
+
useEffect(() => {
if (!user) return;
let trackedDay = "";
@@ -436,7 +485,15 @@ function App() {
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
+ track("PDF_EXPORTED", {
+ destination: drive ? "drive" : "download",
+ success: true,
+ });
} catch (e) {
+ track("PDF_EXPORTED", {
+ destination: drive ? "drive" : "download",
+ success: false,
+ });
fail(e);
} finally {
setBusy(false);
@@ -514,7 +571,11 @@ function App() {
setTheme(theme === "dark" ? "light" : "dark")}
+ onClick={() => {
+ const next = theme === "dark" ? "light" : "dark";
+ setTheme(next);
+ track("THEME_CHANGED", { theme: next });
+ }}
>
{theme === "dark" ? : }
@@ -523,6 +584,7 @@ function App() {
className="account"
onClick={() => {
void save()
+ .then(() => flushAnalytics())
.then(() => post("/auth/logout", {}))
.then(() =>
window.AlgostepNative?.postMessage(
@@ -586,12 +648,14 @@ function App() {
{
if (error.status === 401) {
+ track("ERROR_RECOVERY", { action: "retry" });
setUser(null);
setError(null);
return;
}
try {
if (!config) setConfig(await api("/config"));
+ track("ERROR_RECOVERY", { action: "retry" });
if (error.code === "NETWORK_ERROR" && user) {
await loadData();
if (saveStatus === "저장 실패") await save();
@@ -822,7 +886,11 @@ function App() {
{groups.map((group, i) => (
-
+ track("CATEGORY_SELECTED", { index: i })}
+ >
{group.name} {group.topics.length}
))}
@@ -1113,7 +1181,10 @@ function App() {
editCode(source, k)}
+ onClick={() => {
+ editCode(source, k);
+ track("LANGUAGE_SELECTED", { language: k });
+ }}
>
{String(v)}
@@ -1217,7 +1288,10 @@ function App() {
setTab(k)}
+ onClick={() => {
+ setTab(k);
+ track("RESULT_TAB_VIEWED", { tab: k });
+ }}
>
{v}
@@ -1262,7 +1336,12 @@ function App() {
setTestIndex(Number(e.target.value))}
+ onChange={(e) => {
+ setTestIndex(Number(e.target.value));
+ track("TEST_SELECTED", {
+ index: Number(e.target.value),
+ });
+ }}
>
{record.execution.result.tests.map(
(t: any, i: number) => (
@@ -1525,7 +1604,14 @@ class AppBoundary extends React.Component<
화면을 다시 열어 주세요. 마지막 자동 저장 이후의 변경은 복구되지 않을
수 있어요.
- 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..7b9f8d0 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 · Redash 확장
+
+마이그레이션 6부터 36종 공통 이벤트, GA4 전송 큐와 개인정보를 제외한 Redash 조회 뷰를 제공한다. 관리자 비즈니스 로그에서 연결 설정 및 전송 상태를 확인한다. 외부 활성화 조건, 전체 이벤트 목록과 운영 절차는 [GA4 · Redash 연동](analytics-integrations.md)을 따른다.
diff --git a/docs/analytics-integrations.md b/docs/analytics-integrations.md
new file mode 100644
index 0000000..065f721
--- /dev/null
+++ b/docs/analytics-integrations.md
@@ -0,0 +1,68 @@
+# GA4 · Redash 비즈니스 이벤트
+
+## 상태와 데이터 흐름
+
+현재 앱의 36종 비즈니스 이벤트를 공통 원장에 저장한다. 서버 작업은 상태 변경과 같은 트랜잭션에서 `business_events`와 `analytics_deliveries`에 기록한다. UI 상호작용은 인증·CSRF가 적용된 `POST /api/analytics/events`를 통해 같은 원장으로 들어간다. GA4는 이 원장에서 Measurement Protocol로 전송하고 Redash는 조회 전용 PostgreSQL 뷰에서 읽는다.
+
+**외부 활성화에 필요한 값:** GA4 웹 측정 ID, Measurement Protocol API Secret, 사용자의 Redash HTTPS 주소와 관리 API 권한. 설정 전에도 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/Redash 원장을 기준으로 한다. 활성화 전 72시간 이상 지난 데이터는 Redash에서만 분석하며 시간을 조작해 GA4에 넣지 않는다.
+- 브라우저 GA 태그의 client_id/session_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은 서버에서만 사용한다.
+
+## Redash 활성화
+
+새 유료 Redash 호스트를 자동 생성하지 않는다. 기존 HTTPS Redash 인스턴스가 필요하다. Redash는 이벤트 수집기가 아니라 읽기 전용 DB 분석 경로다.
+
+1. 마이그레이션 6을 적용한 후, 서비스 소유자 DB 연결·CA·32자 이상 임의 비밀번호를 보안 환경 변수로 전달해 `npx tsx scripts/provision-redash-role.ts`를 실행한다. `DATABASE_URL`, `DATABASE_SSL_CA`, `REDASH_DB_PASSWORD`가 필요하다. 스크립트는 관리 표식이 있는 `algostep_redash` 역할만 만들거나 갱신한다. 기존에 다른 용도로 만든 동명 역할은 변경하지 않는다.
+2. 조회 계정은 `algostep.analytics_events`, `analytics_visitors`, `analytics_users`, `analytics_costs`, `analytics_revenue`, `analytics_inquiries`, `analytics_delivery_status` 7개 뷰의 SELECT만 허용한다. 기본 테이블 권한·다른 역할 멤버십이 발견되면 트랜잭션을 롤백한다. 기본 read-only, 15초 문장 제한, 3개 연결 제한을 둔다. 이 계정으로 코드/세션/문의 원문을 읽을 수 없어야 한다.
+3. Redash에 제공할 **조회 전용** 연결 URL을 생성해 보안 환경 변수 `REDASH_DATABASE_URL`로 전달한다. Supabase 세션 pooler 사용자명은 `algostep_redash.`를 사용한다. 서비스의 postgres 관리자 URL을 Redash에 넣지 않는다. 해당 프로젝트의 CA로 TLS `verify-full` 검증을 유지한다.
+4. `REDASH_URL`, `REDASH_API_KEY`, `REDASH_DATABASE_URL`, `DATABASE_SSL_CA` 환경에서 `node scripts/bootstrap-redash.mjs`를 실행한다. 관리 권한으로 데이터 소스·13개 쿼리·대시보드 위젯을 만들고 각 쿼리를 실제 실행한다. 오류에 비밀 값이나 결과 행을 출력하지 않는다. 기본 위젯은 조회 결과 표다.
+5. `tmp/redash-state.json`은 동일 대상에 재실행하기 위한 ID 매핑이다. API 키/비밀번호를 포함하지 않지만 Git에 넣지 않는다. 같은 이름의 기존 데이터 소스에 이 상태 파일 없이 임의 덮어쓰기를 하지 않는다. Redash 사용자/그룹 접근 권한을 관리자에게만 제한하고 공개 대시보드 공유를 켜지 않는다.
+6. Cloud Run `redash_url`을 연결한 주소로 설정하면 관리자 화면에 링크가 표시된다. 주소가 존재한다는 사실만으로 데이터 소스 연결/쿼리 실행 성공을 표시하지 않는다.
+
+13개 쿼리는 누적·일·주·월 접속자, 일/주/월 추이, 가입 사용자, 이벤트 목록, 학습 활동별 고유 사용자, 실행 판정, 비용, 매출/환불, 문의 상태, GA4 전달 상태, 최근 이벤트 상세다. 활동별 사용자 수는 순서가 증명된 전환 퍼널이 아니다. KST 기준 월요일 시작 주간 및 달력 월간, 통화별 분리 집계다. 기존 청구 자료 가져오기에 기반하므로 비용 미수집을 0원으로 바꾸지 않는다.
+
+## API / 운영 확인
+
+- `POST /api/analytics/events`: 로그인·CSRF 필수, 배열 1~25건, 경로별 분당 60요청(기본 IP 제한). 허용 타입/필드 외 입력은 전체 배치를 거절한다.
+- `GET /api/admin/analytics`: 관리자 전용. 설정 여부, 전송 상태 건수, 안전한 Redash 주소, 이벤트 카탈로그. 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), [Redash API](https://redash.io/help/user-guide/integrations-and-api/api/).
diff --git a/docs/implementation-status.md b/docs/implementation-status.md
index eb520df..fc32809 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 Measurement Protocol 전송 outbox, Redash 조회 전용 뷰 7개/쿼리 13개/연결 스크립트를 구현했다. 관리자 화면에서 설정 및 전송 상태를 확인한다. GA4 측정 ID·API Secret 및 Redash 주소·권한은 확인 대기이며 외부 연결 완료 상태가 아니다. [연결 절차 및 한계](analytics-integrations.md)를 참고한다.
diff --git a/docs/verification.md b/docs/verification.md
index 43394bd..c213d64 100644
--- a/docs/verification.md
+++ b/docs/verification.md
@@ -98,3 +98,11 @@ 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 연결 성공으로 표시하지 않는다.
diff --git a/infra/gcp/main.tf b/infra/gcp/main.tf
index dcf5fb2..e356f6d 100644
--- a/infra/gcp/main.tf
+++ b/infra/gcp/main.tf
@@ -52,9 +52,24 @@ 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 "redash_url" {
+ type = string
+ default = ""
+ description = "Private Redash base URL; no API key or public dashboard token."
+}
+
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 and GA4_API_SECRET. 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 +172,10 @@ 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,
+ REDASH_URL = var.redash_url,
+ 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/bootstrap-redash.mjs b/scripts/bootstrap-redash.mjs
new file mode 100644
index 0000000..2e3ef8e
--- /dev/null
+++ b/scripts/bootstrap-redash.mjs
@@ -0,0 +1,164 @@
+import { readFileSync, writeFileSync, existsSync } from "node:fs";
+const base = new URL(process.env.REDASH_URL ?? "https://invalid.local");
+if (
+ base.protocol !== "https:" ||
+ base.username ||
+ base.password ||
+ base.search ||
+ base.hash ||
+ !process.env.REDASH_API_KEY ||
+ !process.env.REDASH_DATABASE_URL ||
+ !process.env.DATABASE_SSL_CA
+)
+ throw new Error(
+ "Set HTTPS REDASH_URL, REDASH_API_KEY, read-only REDASH_DATABASE_URL and DATABASE_SSL_CA",
+ );
+const source = new URL(process.env.REDASH_DATABASE_URL);
+if (
+ !/^algostep_redash(?:\.[a-z0-9]+)?$/.test(decodeURIComponent(source.username))
+)
+ throw new Error("Only the restricted analytics reader may be used");
+const origin = base.href.replace(/\/$/, "");
+const statePath = process.env.REDASH_STATE_PATH ?? "tmp/redash-state.json";
+const state = existsSync(statePath)
+ ? JSON.parse(readFileSync(statePath, "utf8"))
+ : { origin, queries: {}, widgets: {} };
+if (state.origin !== origin)
+ throw new Error("State belongs to another Redash destination");
+const save = () => writeFileSync(statePath, JSON.stringify(state, null, 2));
+async function api(path, method = "GET", data) {
+ const r = await fetch(origin + path, {
+ method,
+ headers: {
+ Authorization: "Key " + process.env.REDASH_API_KEY,
+ "Content-Type": "application/json",
+ },
+ ...(data ? { body: JSON.stringify(data) } : {}),
+ redirect: "error",
+ signal: AbortSignal.timeout(20000),
+ });
+ if (!r.ok) throw new Error("REDASH_HTTP_" + r.status);
+ return r.json();
+}
+try {
+ const sources = await api("/api/data_sources");
+ const existing = sources.find(
+ (s) => s.name === "Algostep analytics (managed)",
+ );
+ if (state.sourceId && (!existing || existing.id !== state.sourceId))
+ throw new Error("Managed data source does not match saved state");
+ if (existing && !state.sourceId)
+ throw new Error(
+ "Existing data source requires original state file; refusing an ambiguous duplicate",
+ );
+ if (!state.sourceId) {
+ const ds = await api("/api/data_sources", "POST", {
+ name: "Algostep analytics (managed)",
+ type: "pg",
+ options: {
+ host: source.hostname,
+ port: Number(source.port || 5432),
+ dbname: source.pathname.slice(1),
+ user: decodeURIComponent(source.username),
+ password: decodeURIComponent(source.password),
+ sslmode: "verify-full",
+ sslrootcertFile: readFileSync(process.env.DATABASE_SSL_CA).toString(
+ "base64",
+ ),
+ dsn: "application_name=algostep-redash connect_timeout=10",
+ },
+ });
+ state.sourceId = ds.id;
+ save();
+ }
+ const checked = await api(
+ `/api/data_sources/${state.sourceId}/test`,
+ "POST",
+ {},
+ );
+ if (checked.ok !== true) throw new Error("DATA_SOURCE_TEST_FAILED");
+ if (!state.dashboardId) {
+ const d = await api("/api/dashboards", "POST", {
+ name: "Algostep 비즈니스 분석",
+ });
+ state.dashboardId = d.id;
+ state.dashboardSlug = d.slug;
+ save();
+ }
+ const definitions = JSON.parse(
+ readFileSync(new URL("./redash-queries.json", import.meta.url), "utf8"),
+ );
+ for (let i = 0; i < definitions.length; i++) {
+ const item = definitions[i];
+ const body = {
+ name: "[Algostep] " + item.name,
+ query: item.query,
+ data_source_id: state.sourceId,
+ description:
+ "Algostep managed analytics query. KST, pseudonymous users, no raw code or inquiry contents.",
+ options: { parameters: [] },
+ is_draft: false,
+ };
+ let q;
+ if (state.queries[item.name])
+ q = await api("/api/queries/" + state.queries[item.name], "POST", body);
+ else {
+ q = await api("/api/queries", "POST", body);
+ state.queries[item.name] = q.id;
+ save();
+ }
+ // Run each query once so a created widget cannot be mistaken for a working
+ // connection. Do not print result rows, credentials or provider errors.
+ let result = await api("/api/queries/" + q.id + "/results", "POST", {
+ max_age: 0,
+ parameters: {},
+ });
+ for (
+ let attempt = 0;
+ result.job && [1, 2].includes(result.job.status) && attempt < 30;
+ attempt++
+ ) {
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ result = await api("/api/jobs/" + result.job.id);
+ }
+ if (!result.query_result && result.job?.status !== 3)
+ throw new Error("QUERY_EXECUTION_FAILED");
+ if (!state.widgets[item.name]) {
+ const visualization = q.visualizations?.find((v) => v.type === "TABLE");
+ if (!visualization) throw new Error("QUERY_VISUALIZATION_MISSING");
+ const widget = await api("/api/widgets", "POST", {
+ dashboard_id: state.dashboardId,
+ visualization_id: visualization.id,
+ options: {
+ position: {
+ col: (i % 2) * 6,
+ row: Math.floor(i / 2) * 8,
+ sizeX: 6,
+ sizeY: 8,
+ },
+ parameterMappings: {},
+ },
+ });
+ state.widgets[item.name] = widget.id;
+ save();
+ }
+ }
+ // Publishing within the authenticated organization is separate from public
+ // sharing. Never enable a dashboard public link or public query API URL.
+ await api("/api/dashboards/" + state.dashboardId, "POST", {
+ name: "Algostep 비즈니스 분석",
+ is_draft: false,
+ });
+ console.log(
+ "Dashboard ready: " + origin + "/dashboard/" + state.dashboardSlug,
+ );
+} catch (e) {
+ // Fetch errors may retain authentication headers; do not print the object.
+ console.error(
+ "Redash setup incomplete. " +
+ (/^REDASH_HTTP_\d+$/.test(e.message)
+ ? e.message
+ : "Check connectivity, API permissions, data source test and the saved state."),
+ );
+ process.exitCode = 1;
+}
diff --git a/scripts/provision-redash-role.ts b/scripts/provision-redash-role.ts
new file mode 100644
index 0000000..bd18766
--- /dev/null
+++ b/scripts/provision-redash-role.ts
@@ -0,0 +1,96 @@
+// Run only with the owner's existing DB connection and a password held in an
+// environment variable/Secret Manager. Never prints credentials or SQL errors.
+import pg from "pg";
+import { readFileSync } from "node:fs";
+const views = [
+ "analytics_events",
+ "analytics_visitors",
+ "analytics_users",
+ "analytics_costs",
+ "analytics_revenue",
+ "analytics_inquiries",
+ "analytics_delivery_status",
+];
+const marker = "Algostep managed analytics reader v1";
+const password = process.env.REDASH_DB_PASSWORD;
+if (
+ !process.env.DATABASE_URL ||
+ !process.env.DATABASE_SSL_CA ||
+ !password ||
+ password.length < 32
+)
+ throw new Error(
+ "Database URL, CA and a 32+ character REDASH_DB_PASSWORD are required",
+ );
+const url = new URL(process.env.DATABASE_URL);
+for (const k of ["sslmode", "sslrootcert", "sslcert", "sslkey"])
+ url.searchParams.delete(k);
+const db = new pg.Client({
+ connectionString: url.toString(),
+ ssl: {
+ ca: readFileSync(process.env.DATABASE_SSL_CA, "utf8"),
+ rejectUnauthorized: true,
+ },
+});
+try {
+ await db.connect();
+ await db.query("BEGIN");
+ const existing = (
+ await db.query(
+ "SELECT oid,shobj_description(oid,'pg_authid') AS description FROM pg_roles WHERE rolname='algostep_redash'",
+ )
+ ).rows[0];
+ if (existing && existing.description !== marker)
+ throw new Error("UNMANAGED_ROLE_EXISTS");
+ const available = (
+ await db.query(
+ "SELECT table_name FROM information_schema.views WHERE table_schema='algostep' AND table_name=ANY($1)",
+ [views],
+ )
+ ).rows;
+ if (available.length !== views.length)
+ throw new Error("ANALYTICS_MIGRATION_REQUIRED");
+ const literal = "'" + password.replaceAll("'", "''") + "'";
+ if (!existing)
+ await db.query(
+ `CREATE ROLE algostep_redash LOGIN PASSWORD ${literal} NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS CONNECTION LIMIT 3`,
+ );
+ else await db.query(`ALTER ROLE algostep_redash PASSWORD ${literal}`);
+ await db.query(
+ "COMMENT ON ROLE algostep_redash IS 'Algostep managed analytics reader v1'",
+ );
+ await db.query(
+ "ALTER ROLE algostep_redash SET search_path TO pg_catalog,algostep",
+ );
+ await db.query(
+ "ALTER ROLE algostep_redash SET default_transaction_read_only TO on",
+ );
+ await db.query("ALTER ROLE algostep_redash SET statement_timeout TO '15s'");
+ await db.query("GRANT USAGE ON SCHEMA algostep TO algostep_redash");
+ for (const view of views)
+ await db.query(`GRANT SELECT ON algostep.${view} TO algostep_redash`);
+ const unsafe = (
+ await db.query(
+ "SELECT tablename FROM pg_tables WHERE schemaname='algostep' AND (has_table_privilege('algostep_redash',quote_ident(schemaname)||'.'||quote_ident(tablename),'SELECT') OR has_table_privilege('algostep_redash',quote_ident(schemaname)||'.'||quote_ident(tablename),'INSERT,UPDATE,DELETE'))",
+ )
+ ).rows;
+ const memberships = (
+ await db.query(
+ "SELECT 1 FROM pg_auth_members WHERE member=(SELECT oid FROM pg_roles WHERE rolname='algostep_redash')",
+ )
+ ).rows;
+ if (unsafe.length || memberships.length)
+ throw new Error("READER_HAS_UNEXPECTED_PRIVILEGES");
+ await db.query("COMMIT");
+ console.log(
+ "Read-only analytics role provisioned; base-table access denied. No credentials printed.",
+ );
+} catch {
+ await db.query("ROLLBACK").catch(() => {});
+ console.error(
+ "Redash reader provisioning failed; changes rolled back. Check owner permissions, migration and managed-role ownership.",
+ );
+ process.exitCode = 1;
+} finally {
+ await db.end();
+}
diff --git a/scripts/redash-queries.json b/scripts/redash-queries.json
new file mode 100644
index 0000000..e5ef7f9
--- /dev/null
+++ b/scripts/redash-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..4dee528
--- /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 · Redash 연결 상태" }),
+ ).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,
+ });
+});
From f858b61ee7bb4f733ffd144bba4dec5ac835fb4b Mon Sep 17 00:00:00 2001
From: Sadturtleman
Date: Sun, 20 Sep 2026 00:17:07 +0900
Subject: [PATCH 2/5] Record analytics deployment verification
---
docs/verification.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/docs/verification.md b/docs/verification.md
index c213d64..fa4e6c4 100644
--- a/docs/verification.md
+++ b/docs/verification.md
@@ -106,3 +106,5 @@ GitHub 업로드와 CI 통과를 운영 출시로 간주하지 않는다. 최신
- 브라우저 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 호스트는 변경/추가하지 않았다.
From 8cf56f72128e9e94da551adab35ff52f4b1c1d5c Mon Sep 17 00:00:00 2001
From: Sadturtleman
Date: Sun, 20 Sep 2026 00:47:42 +0900
Subject: [PATCH 3/5] Replace Redash setup with Amplitude event delivery
---
.env.example | 6 +-
apps/server/src/amplitude.ts | 184 ++++++++++++++++++
apps/server/src/business.ts | 8 +-
apps/server/src/ga4.ts | 17 +-
apps/server/src/maintenance.ts | 9 +-
apps/server/src/migrations.ts | 6 +
apps/server/test/amplitude.test.ts | 133 +++++++++++++
apps/server/test/analytics.test.ts | 4 +-
apps/server/test/expansion.test.ts | 2 +-
apps/web/src/AdminDashboard.tsx | 26 ++-
docs/admin-dashboard.md | 4 +-
docs/analytics-integrations.md | 30 +--
docs/implementation-status.md | 2 +-
docs/verification.md | 7 +
infra/gcp/main.tf | 24 ++-
scripts/bootstrap-redash.mjs | 164 ----------------
...ash-queries.json => business-queries.json} | 0
scripts/provision-redash-role.ts | 96 ---------
tests/browser/analytics.spec.ts | 2 +-
19 files changed, 406 insertions(+), 318 deletions(-)
create mode 100644 apps/server/src/amplitude.ts
create mode 100644 apps/server/test/amplitude.test.ts
delete mode 100644 scripts/bootstrap-redash.mjs
rename scripts/{redash-queries.json => business-queries.json} (100%)
delete mode 100644 scripts/provision-redash-role.ts
diff --git a/.env.example b/.env.example
index 84681f4..8b31b18 100644
--- a/.env.example
+++ b/.env.example
@@ -12,8 +12,10 @@ ADMIN_EMAILS=
# GA4 Measurement Protocol API secret stays server-side (Secret Manager in production).
GA4_MEASUREMENT_ID=
GA4_API_SECRET=
-# Redash reads restricted SQL views. Never use the application owner DB password.
-REDASH_URL=
+# 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/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/business.ts b/apps/server/src/business.ts
index 473d35f..8ec761e 100644
--- a/apps/server/src/business.ts
+++ b/apps/server/src/business.ts
@@ -20,8 +20,9 @@ export async function businessEvent(
`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)
- INSERT INTO analytics_deliveries(event_id) SELECT id FROM inserted`,
+ 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,
@@ -41,7 +42,8 @@ export async function visit(db: DB, user: string) {
), 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
- ) INSERT INTO analytics_deliveries(event_id) SELECT id FROM e`,
+ ), 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/ga4.ts b/apps/server/src/ga4.ts
index f77b890..9fcaac0 100644
--- a/apps/server/src/ga4.ts
+++ b/apps/server/src/ga4.ts
@@ -60,7 +60,7 @@ export function ga4Payload(row: Record) {
// 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. Redash reads the exact DB ledger.
+// are quarantined, never automatically resent. Operational metrics use the exact DB ledger.
export async function deliverGa4(
db: DB,
config = ga4Config(),
@@ -184,18 +184,7 @@ export async function deliverGa4(
export async function analyticsStatus(db: DB) {
const config = ga4Config();
- let redashUrl: string | null = null;
- try {
- const u = new URL(process.env.REDASH_URL ?? "");
- if (
- u.protocol === "https:" &&
- !u.username &&
- !u.password &&
- !u.search &&
- !u.hash
- )
- redashUrl = u.href;
- } catch {}
+ const { amplitudeStatus } = await import("./amplitude.js");
return {
ga4: {
configured: !!config,
@@ -206,7 +195,7 @@ export async function analyticsStatus(db: DB) {
)
).rows,
},
- redash: { url: redashUrl, source: "read-only PostgreSQL analytics views" },
+ 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 26cb73e..f985e48 100644
--- a/apps/server/src/maintenance.ts
+++ b/apps/server/src/maintenance.ts
@@ -5,6 +5,7 @@ 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);
@@ -23,6 +24,12 @@ export async function maintenance(db: DB, storage?: ObjectStorage) {
}
const llm = llmOptions();
if (llm) await processReview(db, llm);
- await deliverGa4(db);
+ // 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 1d08a1d..f4176fa 100644
--- a/apps/server/src/migrations.ts
+++ b/apps/server/src/migrations.ts
@@ -65,6 +65,12 @@ const migrations = [
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/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
index 4d7bfd7..d857d20 100644
--- a/apps/server/test/analytics.test.ts
+++ b/apps/server/test/analytics.test.ts
@@ -294,7 +294,7 @@ test("pre-collect transport failure backs off; disabled, expired and abandoned l
});
assert.equal((await statuses())[0].status, "UNCERTAIN");
});
-test("Redash views expose pseudonymous data, all dashboard SQL runs, PUBLIC cannot select views", async () => {
+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];
@@ -306,7 +306,7 @@ test("Redash views expose pseudonymous data, all dashboard SQL runs, PUBLIC cann
);
for (const q of JSON.parse(
readFileSync(
- new URL("../../../scripts/redash-queries.json", import.meta.url),
+ new URL("../../../scripts/business-queries.json", import.meta.url),
"utf8",
),
)) {
diff --git a/apps/server/test/expansion.test.ts b/apps/server/test/expansion.test.ts
index 6983fff..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,
- 6,
+ 7,
);
} finally {
await db.close();
diff --git a/apps/web/src/AdminDashboard.tsx b/apps/web/src/AdminDashboard.tsx
index 2c4a9a7..86fb23f 100644
--- a/apps/web/src/AdminDashboard.tsx
+++ b/apps/web/src/AdminDashboard.tsx
@@ -492,13 +492,14 @@ function AnalyticsConnections() {
PENDING: "대기",
PROCESSING: "전송 중",
ACCEPTED: "HTTP 수신 확인",
+ INGESTED: "수집 응답 확인",
REJECTED: "거절",
UNCERTAIN: "수신 여부 불명",
EXPIRED: "전송 기한 만료",
};
return (
- GA4 · Redash 연결 상태
+ GA4 · Amplitude 연결 상태
{error && {error}
}
{!data && !error && 연결 상태를 불러오는 중…
}
{data && (
@@ -510,18 +511,24 @@ function AnalyticsConnections() {
: "연결 설정 대기"}
- Redash:{" "}
- {data.redash.url ? (
-
+ Amplitude:{" "}
+ {data.amplitude.configured ? "전송 설정 완료" : "연결 설정 대기"}{" "}
+ {data.amplitude.url && (
+
분석 대시보드 열기
- ) : (
- "연결 설정 대기"
)}
[
+ headers={["분석 서비스", "전송 상태", "이벤트 수", "상태 코드"]}
+ rows={[
+ ...data.ga4.states.map((r: any) => ({ ...r, provider: "GA4" })),
+ ...data.amplitude.states.map((r: any) => ({
+ ...r,
+ provider: "Amplitude",
+ })),
+ ].map((r: any) => [
+ r.provider,
labels[r.status] ?? r.status,
number(r.count),
r.last_code ?? "—",
@@ -531,7 +538,8 @@ function AnalyticsConnections() {
현재 {data.catalog.length}종 이벤트를 수집합니다. GA4의 HTTP 수신
확인은 보고서 반영 완료를 뜻하지 않습니다. 수신 여부가 불명확한 건은
중복 집계를 막기 위해 자동 재전송하지 않으며, 원본 이벤트는
- 데이터베이스에 남습니다.
+ 데이터베이스에 남습니다. Amplitude는 동일 이벤트 식별자로
+ 재시도하며, 최초 전송 후 6일이 지나면 재시도를 종료합니다.
>
)}
diff --git a/docs/admin-dashboard.md b/docs/admin-dashboard.md
index 7b9f8d0..e59a86a 100644
--- a/docs/admin-dashboard.md
+++ b/docs/admin-dashboard.md
@@ -53,6 +53,6 @@ DB 마이그레이션 5에서 `visitor_days`, `business_events`, `support_ticket
관리자 401/403과 CSRF, KST 경계, 고유 사용자 집계, 수집 전 기간, 문의 소유권/재시도/답변 충돌, 비용 통화 분리·중복·원자성·환불 집계, 기록 삭제 후 로그 보존을 통합 검사한다. Playwright로 일반 사용자→관리자→답변 확인, 비용 업로드, 차트·로그 화면, 다크모드와 모바일 너비를 확인한다. 브라우저 비용 데이터는 격리된 테스트 DB에만 입력한다.
-## GA4 · Redash 확장
+## GA4 · Amplitude 확장
-마이그레이션 6부터 36종 공통 이벤트, GA4 전송 큐와 개인정보를 제외한 Redash 조회 뷰를 제공한다. 관리자 비즈니스 로그에서 연결 설정 및 전송 상태를 확인한다. 외부 활성화 조건, 전체 이벤트 목록과 운영 절차는 [GA4 · Redash 연동](analytics-integrations.md)을 따른다.
+마이그레이션 6·7에서 36종 공통 이벤트와 GA4·Amplitude 각각의 전송 큐를 제공한다. 내부 SQL 조회 뷰는 유지한다. 관리자 비즈니스 로그에서 연결 설정 및 전송 상태를 확인한다. 외부 활성화 조건, 전체 이벤트 목록과 운영 절차는 [GA4 · Amplitude 연동](analytics-integrations.md)을 따른다.
diff --git a/docs/analytics-integrations.md b/docs/analytics-integrations.md
index 065f721..e1db375 100644
--- a/docs/analytics-integrations.md
+++ b/docs/analytics-integrations.md
@@ -1,10 +1,10 @@
-# GA4 · Redash 비즈니스 이벤트
+# GA4 · Amplitude 비즈니스 이벤트
## 상태와 데이터 흐름
-현재 앱의 36종 비즈니스 이벤트를 공통 원장에 저장한다. 서버 작업은 상태 변경과 같은 트랜잭션에서 `business_events`와 `analytics_deliveries`에 기록한다. UI 상호작용은 인증·CSRF가 적용된 `POST /api/analytics/events`를 통해 같은 원장으로 들어간다. GA4는 이 원장에서 Measurement Protocol로 전송하고 Redash는 조회 전용 PostgreSQL 뷰에서 읽는다.
+현재 앱의 36종 비즈니스 이벤트를 공통 원장에 저장한다. 서버 작업은 상태 변경과 같은 트랜잭션에서 `business_events`와 `analytics_deliveries`에 기록한다. UI 상호작용은 인증·CSRF가 적용된 `POST /api/analytics/events`를 통해 같은 원장으로 들어간다. GA4는 이 원장에서 Measurement Protocol로 전송하고 Amplitude는 별도 큐에서 HTTP V2 API로 전송한다.
-**외부 활성화에 필요한 값:** GA4 웹 측정 ID, Measurement Protocol API Secret, 사용자의 Redash HTTPS 주소와 관리 API 권한. 설정 전에도 DB 수집은 동작하며 관리자 → 비즈니스 로그에 `연결 설정 대기`로 표시한다. 연결 설정 완료와 외부 보고서 수신 검증은 다르다. 이 문서나 코드의 존재만으로 외부 연결 완료를 뜻하지 않는다.
+**외부 활성화에 필요한 값:** GA4 웹 측정 ID, Measurement Protocol API Secret, Amplitude 프로젝트 수집 키와 리전. 설정 전에도 DB 수집은 동작하며 관리자 → 비즈니스 로그에 `연결 설정 대기`로 표시한다. 연결 설정 완료와 외부 보고서 수신 검증은 다르다. 이 문서나 코드의 존재만으로 외부 연결 완료를 뜻하지 않는다.
## 이벤트 목록
@@ -32,7 +32,7 @@ GA4 이름은 아래 코드에 소문자 `as_` 접두어를 붙인다. 예: `EXE
- 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/Redash 원장을 기준으로 한다. 활성화 전 72시간 이상 지난 데이터는 Redash에서만 분석하며 시간을 조작해 GA4에 넣지 않는다.
+- GA4는 일반 이벤트 ID의 exactly-once 처리를 보장하지 않는다. 정확한 운영 집계는 DB 원장을 기준으로 한다. 활성화 전 72시간 이상 지난 데이터는 DB 원장에서 분석하며 시간을 조작해 GA4에 넣지 않는다.
- 브라우저 GA 태그의 client_id/session_id와 임의 생성한 analytics 사용자 UUID만 전달한다. 태그 차단·Android WebView 등 컨텍스트가 없으면 `server_unattributed`로 구분한다. 이를 정상 웹 세션·체류 시간 지표로 해석하지 않는다. engagement_time을 임의 생성하지 않는다.
- Google 태그 자체의 page_view/first_visit/session_start 등 자동 이벤트는 GA4의 웹 계측이며 36종 앱 이벤트 원장과 별도다. 앱 이벤트를 Google 태그와 서버 양쪽에서 중복 전송하지 않는다.
@@ -46,23 +46,23 @@ GA4 이름은 아래 코드에 소문자 `as_` 접두어를 붙인다. 예: `EXE
코드는 로그인 사용자에 한해 태그를 로드하고 로그아웃하면 추적을 비활성화한다. 전송에 코드·이메일·Google sub·문의 본문·검색어·개별 기록 URL·인증 토큰을 포함하지 않는다. 브라우저 page URL은 서비스 origin으로 고정한다. API Secret은 서버에서만 사용한다.
-## Redash 활성화
+## Amplitude 활성화
-새 유료 Redash 호스트를 자동 생성하지 않는다. 기존 HTTPS Redash 인스턴스가 필요하다. Redash는 이벤트 수집기가 아니라 읽기 전용 DB 분석 경로다.
+GA4와 같은 원장에서 HTTP V2 API로 전송한다. 별도 분석 서버나 DB 접속 계정은 필요하지 않다. 기존 SQL 뷰는 내부 운영 조회용으로 유지한다.
-1. 마이그레이션 6을 적용한 후, 서비스 소유자 DB 연결·CA·32자 이상 임의 비밀번호를 보안 환경 변수로 전달해 `npx tsx scripts/provision-redash-role.ts`를 실행한다. `DATABASE_URL`, `DATABASE_SSL_CA`, `REDASH_DB_PASSWORD`가 필요하다. 스크립트는 관리 표식이 있는 `algostep_redash` 역할만 만들거나 갱신한다. 기존에 다른 용도로 만든 동명 역할은 변경하지 않는다.
-2. 조회 계정은 `algostep.analytics_events`, `analytics_visitors`, `analytics_users`, `analytics_costs`, `analytics_revenue`, `analytics_inquiries`, `analytics_delivery_status` 7개 뷰의 SELECT만 허용한다. 기본 테이블 권한·다른 역할 멤버십이 발견되면 트랜잭션을 롤백한다. 기본 read-only, 15초 문장 제한, 3개 연결 제한을 둔다. 이 계정으로 코드/세션/문의 원문을 읽을 수 없어야 한다.
-3. Redash에 제공할 **조회 전용** 연결 URL을 생성해 보안 환경 변수 `REDASH_DATABASE_URL`로 전달한다. Supabase 세션 pooler 사용자명은 `algostep_redash.`를 사용한다. 서비스의 postgres 관리자 URL을 Redash에 넣지 않는다. 해당 프로젝트의 CA로 TLS `verify-full` 검증을 유지한다.
-4. `REDASH_URL`, `REDASH_API_KEY`, `REDASH_DATABASE_URL`, `DATABASE_SSL_CA` 환경에서 `node scripts/bootstrap-redash.mjs`를 실행한다. 관리 권한으로 데이터 소스·13개 쿼리·대시보드 위젯을 만들고 각 쿼리를 실제 실행한다. 오류에 비밀 값이나 결과 행을 출력하지 않는다. 기본 위젯은 조회 결과 표다.
-5. `tmp/redash-state.json`은 동일 대상에 재실행하기 위한 ID 매핑이다. API 키/비밀번호를 포함하지 않지만 Git에 넣지 않는다. 같은 이름의 기존 데이터 소스에 이 상태 파일 없이 임의 덮어쓰기를 하지 않는다. Redash 사용자/그룹 접근 권한을 관리자에게만 제한하고 공개 대시보드 공유를 켜지 않는다.
-6. Cloud Run `redash_url`을 연결한 주소로 설정하면 관리자 화면에 링크가 표시된다. 주소가 존재한다는 사실만으로 데이터 소스 연결/쿼리 실행 성공을 표시하지 않는다.
+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는 전달하지 않는다.
-13개 쿼리는 누적·일·주·월 접속자, 일/주/월 추이, 가입 사용자, 이벤트 목록, 학습 활동별 고유 사용자, 실행 판정, 비용, 매출/환불, 문의 상태, GA4 전달 상태, 최근 이벤트 상세다. 활동별 사용자 수는 순서가 증명된 전환 퍼널이 아니다. KST 기준 월요일 시작 주간 및 달력 월간, 통화별 분리 집계다. 기존 청구 자료 가져오기에 기반하므로 비용 미수집을 0원으로 바꾸지 않는다.
+가입·접속·학습·실행·리뷰 전환은 Amplitude에서 분석한다. 실제 비용·매출·문의 운영 지표는 기존 관리자 대시보드와 DB 원장을 기준으로 한다. 결제 연동 전 구매 이벤트를 만들지 않는다. 유료 플랜은 자동 구매하지 않는다.
## API / 운영 확인
- `POST /api/analytics/events`: 로그인·CSRF 필수, 배열 1~25건, 경로별 분당 60요청(기본 IP 제한). 허용 타입/필드 외 입력은 전체 배치를 거절한다.
-- `GET /api/admin/analytics`: 관리자 전용. 설정 여부, 전송 상태 건수, 안전한 Redash 주소, 이벤트 카탈로그. API Secret을 반환하지 않는다.
+- `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), [Redash API](https://redash.io/help/user-guide/integrations-and-api/api/).
+참고: [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).
diff --git a/docs/implementation-status.md b/docs/implementation-status.md
index fc32809..5e37495 100644
--- a/docs/implementation-status.md
+++ b/docs/implementation-status.md
@@ -58,4 +58,4 @@
## 비즈니스 분석 확장 (2026-09-20)
-36종 이벤트 원장과 GA4 Measurement Protocol 전송 outbox, Redash 조회 전용 뷰 7개/쿼리 13개/연결 스크립트를 구현했다. 관리자 화면에서 설정 및 전송 상태를 확인한다. GA4 측정 ID·API Secret 및 Redash 주소·권한은 확인 대기이며 외부 연결 완료 상태가 아니다. [연결 절차 및 한계](analytics-integrations.md)를 참고한다.
+36종 이벤트 원장과 GA4·Amplitude 독립 전송 큐를 구현했다. 관리자 화면은 공급자별 설정·전송 상태를 표시한다. GA4 계정과 웹 스트림은 생성됐으며, 사용자 데이터 수집 확인과 두 서비스의 키 생성·연결 승인을 기다리고 있다. 이번 Amplitude 변경은 아직 배포 전이다. [연결 절차 및 한계](analytics-integrations.md)를 참고한다.
diff --git a/docs/verification.md b/docs/verification.md
index fa4e6c4..e56f3da 100644
--- a/docs/verification.md
+++ b/docs/verification.md
@@ -108,3 +108,10 @@ GitHub 업로드와 CI 통과를 운영 출시로 간주하지 않는다. 최신
- 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 키 생성/저장 승인은 대기 중이다. 실제 공급자 수집이나 배포 완료로 표시하지 않는다.
diff --git a/infra/gcp/main.tf b/infra/gcp/main.tf
index e356f6d..1dc3d50 100644
--- a/infra/gcp/main.tf
+++ b/infra/gcp/main.tf
@@ -61,15 +61,24 @@ variable "ga4_measurement_id" {
}
}
-variable "redash_url" {
+variable "amplitude_project_url" {
type = string
default = ""
- description = "Private Redash base URL; no API key or public dashboard token."
+ 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 and GA4_API_SECRET. 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)])
@@ -172,10 +181,11 @@ resource "google_cloud_run_v2_service" "api" {
dynamic "env" {
for_each = merge({
- ADMIN_EMAILS = join(",", var.admin_emails),
- GA4_MEASUREMENT_ID = var.ga4_measurement_id,
- REDASH_URL = var.redash_url,
- 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/bootstrap-redash.mjs b/scripts/bootstrap-redash.mjs
deleted file mode 100644
index 2e3ef8e..0000000
--- a/scripts/bootstrap-redash.mjs
+++ /dev/null
@@ -1,164 +0,0 @@
-import { readFileSync, writeFileSync, existsSync } from "node:fs";
-const base = new URL(process.env.REDASH_URL ?? "https://invalid.local");
-if (
- base.protocol !== "https:" ||
- base.username ||
- base.password ||
- base.search ||
- base.hash ||
- !process.env.REDASH_API_KEY ||
- !process.env.REDASH_DATABASE_URL ||
- !process.env.DATABASE_SSL_CA
-)
- throw new Error(
- "Set HTTPS REDASH_URL, REDASH_API_KEY, read-only REDASH_DATABASE_URL and DATABASE_SSL_CA",
- );
-const source = new URL(process.env.REDASH_DATABASE_URL);
-if (
- !/^algostep_redash(?:\.[a-z0-9]+)?$/.test(decodeURIComponent(source.username))
-)
- throw new Error("Only the restricted analytics reader may be used");
-const origin = base.href.replace(/\/$/, "");
-const statePath = process.env.REDASH_STATE_PATH ?? "tmp/redash-state.json";
-const state = existsSync(statePath)
- ? JSON.parse(readFileSync(statePath, "utf8"))
- : { origin, queries: {}, widgets: {} };
-if (state.origin !== origin)
- throw new Error("State belongs to another Redash destination");
-const save = () => writeFileSync(statePath, JSON.stringify(state, null, 2));
-async function api(path, method = "GET", data) {
- const r = await fetch(origin + path, {
- method,
- headers: {
- Authorization: "Key " + process.env.REDASH_API_KEY,
- "Content-Type": "application/json",
- },
- ...(data ? { body: JSON.stringify(data) } : {}),
- redirect: "error",
- signal: AbortSignal.timeout(20000),
- });
- if (!r.ok) throw new Error("REDASH_HTTP_" + r.status);
- return r.json();
-}
-try {
- const sources = await api("/api/data_sources");
- const existing = sources.find(
- (s) => s.name === "Algostep analytics (managed)",
- );
- if (state.sourceId && (!existing || existing.id !== state.sourceId))
- throw new Error("Managed data source does not match saved state");
- if (existing && !state.sourceId)
- throw new Error(
- "Existing data source requires original state file; refusing an ambiguous duplicate",
- );
- if (!state.sourceId) {
- const ds = await api("/api/data_sources", "POST", {
- name: "Algostep analytics (managed)",
- type: "pg",
- options: {
- host: source.hostname,
- port: Number(source.port || 5432),
- dbname: source.pathname.slice(1),
- user: decodeURIComponent(source.username),
- password: decodeURIComponent(source.password),
- sslmode: "verify-full",
- sslrootcertFile: readFileSync(process.env.DATABASE_SSL_CA).toString(
- "base64",
- ),
- dsn: "application_name=algostep-redash connect_timeout=10",
- },
- });
- state.sourceId = ds.id;
- save();
- }
- const checked = await api(
- `/api/data_sources/${state.sourceId}/test`,
- "POST",
- {},
- );
- if (checked.ok !== true) throw new Error("DATA_SOURCE_TEST_FAILED");
- if (!state.dashboardId) {
- const d = await api("/api/dashboards", "POST", {
- name: "Algostep 비즈니스 분석",
- });
- state.dashboardId = d.id;
- state.dashboardSlug = d.slug;
- save();
- }
- const definitions = JSON.parse(
- readFileSync(new URL("./redash-queries.json", import.meta.url), "utf8"),
- );
- for (let i = 0; i < definitions.length; i++) {
- const item = definitions[i];
- const body = {
- name: "[Algostep] " + item.name,
- query: item.query,
- data_source_id: state.sourceId,
- description:
- "Algostep managed analytics query. KST, pseudonymous users, no raw code or inquiry contents.",
- options: { parameters: [] },
- is_draft: false,
- };
- let q;
- if (state.queries[item.name])
- q = await api("/api/queries/" + state.queries[item.name], "POST", body);
- else {
- q = await api("/api/queries", "POST", body);
- state.queries[item.name] = q.id;
- save();
- }
- // Run each query once so a created widget cannot be mistaken for a working
- // connection. Do not print result rows, credentials or provider errors.
- let result = await api("/api/queries/" + q.id + "/results", "POST", {
- max_age: 0,
- parameters: {},
- });
- for (
- let attempt = 0;
- result.job && [1, 2].includes(result.job.status) && attempt < 30;
- attempt++
- ) {
- await new Promise((resolve) => setTimeout(resolve, 1000));
- result = await api("/api/jobs/" + result.job.id);
- }
- if (!result.query_result && result.job?.status !== 3)
- throw new Error("QUERY_EXECUTION_FAILED");
- if (!state.widgets[item.name]) {
- const visualization = q.visualizations?.find((v) => v.type === "TABLE");
- if (!visualization) throw new Error("QUERY_VISUALIZATION_MISSING");
- const widget = await api("/api/widgets", "POST", {
- dashboard_id: state.dashboardId,
- visualization_id: visualization.id,
- options: {
- position: {
- col: (i % 2) * 6,
- row: Math.floor(i / 2) * 8,
- sizeX: 6,
- sizeY: 8,
- },
- parameterMappings: {},
- },
- });
- state.widgets[item.name] = widget.id;
- save();
- }
- }
- // Publishing within the authenticated organization is separate from public
- // sharing. Never enable a dashboard public link or public query API URL.
- await api("/api/dashboards/" + state.dashboardId, "POST", {
- name: "Algostep 비즈니스 분석",
- is_draft: false,
- });
- console.log(
- "Dashboard ready: " + origin + "/dashboard/" + state.dashboardSlug,
- );
-} catch (e) {
- // Fetch errors may retain authentication headers; do not print the object.
- console.error(
- "Redash setup incomplete. " +
- (/^REDASH_HTTP_\d+$/.test(e.message)
- ? e.message
- : "Check connectivity, API permissions, data source test and the saved state."),
- );
- process.exitCode = 1;
-}
diff --git a/scripts/redash-queries.json b/scripts/business-queries.json
similarity index 100%
rename from scripts/redash-queries.json
rename to scripts/business-queries.json
diff --git a/scripts/provision-redash-role.ts b/scripts/provision-redash-role.ts
deleted file mode 100644
index bd18766..0000000
--- a/scripts/provision-redash-role.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-// Run only with the owner's existing DB connection and a password held in an
-// environment variable/Secret Manager. Never prints credentials or SQL errors.
-import pg from "pg";
-import { readFileSync } from "node:fs";
-const views = [
- "analytics_events",
- "analytics_visitors",
- "analytics_users",
- "analytics_costs",
- "analytics_revenue",
- "analytics_inquiries",
- "analytics_delivery_status",
-];
-const marker = "Algostep managed analytics reader v1";
-const password = process.env.REDASH_DB_PASSWORD;
-if (
- !process.env.DATABASE_URL ||
- !process.env.DATABASE_SSL_CA ||
- !password ||
- password.length < 32
-)
- throw new Error(
- "Database URL, CA and a 32+ character REDASH_DB_PASSWORD are required",
- );
-const url = new URL(process.env.DATABASE_URL);
-for (const k of ["sslmode", "sslrootcert", "sslcert", "sslkey"])
- url.searchParams.delete(k);
-const db = new pg.Client({
- connectionString: url.toString(),
- ssl: {
- ca: readFileSync(process.env.DATABASE_SSL_CA, "utf8"),
- rejectUnauthorized: true,
- },
-});
-try {
- await db.connect();
- await db.query("BEGIN");
- const existing = (
- await db.query(
- "SELECT oid,shobj_description(oid,'pg_authid') AS description FROM pg_roles WHERE rolname='algostep_redash'",
- )
- ).rows[0];
- if (existing && existing.description !== marker)
- throw new Error("UNMANAGED_ROLE_EXISTS");
- const available = (
- await db.query(
- "SELECT table_name FROM information_schema.views WHERE table_schema='algostep' AND table_name=ANY($1)",
- [views],
- )
- ).rows;
- if (available.length !== views.length)
- throw new Error("ANALYTICS_MIGRATION_REQUIRED");
- const literal = "'" + password.replaceAll("'", "''") + "'";
- if (!existing)
- await db.query(
- `CREATE ROLE algostep_redash LOGIN PASSWORD ${literal} NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS CONNECTION LIMIT 3`,
- );
- else await db.query(`ALTER ROLE algostep_redash PASSWORD ${literal}`);
- await db.query(
- "COMMENT ON ROLE algostep_redash IS 'Algostep managed analytics reader v1'",
- );
- await db.query(
- "ALTER ROLE algostep_redash SET search_path TO pg_catalog,algostep",
- );
- await db.query(
- "ALTER ROLE algostep_redash SET default_transaction_read_only TO on",
- );
- await db.query("ALTER ROLE algostep_redash SET statement_timeout TO '15s'");
- await db.query("GRANT USAGE ON SCHEMA algostep TO algostep_redash");
- for (const view of views)
- await db.query(`GRANT SELECT ON algostep.${view} TO algostep_redash`);
- const unsafe = (
- await db.query(
- "SELECT tablename FROM pg_tables WHERE schemaname='algostep' AND (has_table_privilege('algostep_redash',quote_ident(schemaname)||'.'||quote_ident(tablename),'SELECT') OR has_table_privilege('algostep_redash',quote_ident(schemaname)||'.'||quote_ident(tablename),'INSERT,UPDATE,DELETE'))",
- )
- ).rows;
- const memberships = (
- await db.query(
- "SELECT 1 FROM pg_auth_members WHERE member=(SELECT oid FROM pg_roles WHERE rolname='algostep_redash')",
- )
- ).rows;
- if (unsafe.length || memberships.length)
- throw new Error("READER_HAS_UNEXPECTED_PRIVILEGES");
- await db.query("COMMIT");
- console.log(
- "Read-only analytics role provisioned; base-table access denied. No credentials printed.",
- );
-} catch {
- await db.query("ROLLBACK").catch(() => {});
- console.error(
- "Redash reader provisioning failed; changes rolled back. Check owner permissions, migration and managed-role ownership.",
- );
- process.exitCode = 1;
-} finally {
- await db.end();
-}
diff --git a/tests/browser/analytics.spec.ts b/tests/browser/analytics.spec.ts
index 4dee528..183d1ca 100644
--- a/tests/browser/analytics.spec.ts
+++ b/tests/browser/analytics.spec.ts
@@ -39,7 +39,7 @@ test("UI events survive a failed batch without duplicates or typed text; admin s
.getByRole("button", { name: "비즈니스 로그", exact: true })
.click();
await expect(
- page.getByRole("heading", { name: "GA4 · Redash 연결 상태" }),
+ page.getByRole("heading", { name: "GA4 · Amplitude 연결 상태" }),
).toBeVisible();
await expect(
page.getByText("GA4: 연결 설정 대기", { exact: true }),
From 27218362cb3dd08c2f7988da38a584496f6f1623 Mon Sep 17 00:00:00 2001
From: Sadturtleman
Date: Sun, 20 Sep 2026 01:09:16 +0900
Subject: [PATCH 4/5] Use valid numeric GA4 identifiers for server-only events
---
apps/server/src/ga4.ts | 10 ++++++++--
apps/server/test/analytics.test.ts | 23 +++++++++++++++++++++++
2 files changed, 31 insertions(+), 2 deletions(-)
diff --git a/apps/server/src/ga4.ts b/apps/server/src/ga4.ts
index 9fcaac0..a05daab 100644
--- a/apps/server/src/ga4.ts
+++ b/apps/server/src/ga4.ts
@@ -1,5 +1,5 @@
import type { DB } from "./db.js";
-import { randomUUID } from "node:crypto";
+import { createHash, randomUUID } from "node:crypto";
import {
eventProperties,
ga4EventName,
@@ -29,6 +29,11 @@ export function ga4Payload(row: Record) {
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",
@@ -43,7 +48,8 @@ export function ga4Payload(row: Record) {
params.session_id = context.sessionId;
return {
client_id:
- context.clientId ?? "server." + (row.analytics_id ?? "algostep-system"),
+ 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" },
diff --git a/apps/server/test/analytics.test.ts b/apps/server/test/analytics.test.ts
index d857d20..1a9fd69 100644
--- a/apps/server/test/analytics.test.ts
+++ b/apps/server/test/analytics.test.ts
@@ -193,6 +193,29 @@ test("GA4 payload excludes raw identity, entity IDs, extra properties and matche
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();
From fe0bbaf8aafc76674a59f79b129a10c19accabfa Mon Sep 17 00:00:00 2001
From: Sadturtleman
Date: Sun, 20 Sep 2026 01:13:50 +0900
Subject: [PATCH 5/5] Document verified production analytics connections
---
docs/analytics-integrations.md | 12 ++++++++++--
docs/implementation-status.md | 2 +-
docs/verification.md | 9 +++++++++
3 files changed, 20 insertions(+), 3 deletions(-)
diff --git a/docs/analytics-integrations.md b/docs/analytics-integrations.md
index e1db375..3e9f6a9 100644
--- a/docs/analytics-integrations.md
+++ b/docs/analytics-integrations.md
@@ -4,7 +4,7 @@
현재 앱의 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 웹 측정 ID, Measurement Protocol API Secret, Amplitude 프로젝트 수집 키와 리전. 설정 전에도 DB 수집은 동작하며 관리자 → 비즈니스 로그에 `연결 설정 대기`로 표시한다. 연결 설정 완료와 외부 보고서 수신 검증은 다르다. 이 문서나 코드의 존재만으로 외부 연결 완료를 뜻하지 않는다.
## 이벤트 목록
@@ -33,7 +33,7 @@ GA4 이름은 아래 코드에 소문자 `as_` 접두어를 붙인다. 예: `EXE
- 기존 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만 전달한다. 태그 차단·Android WebView 등 컨텍스트가 없으면 `server_unattributed`로 구분한다. 이를 정상 웹 세션·체류 시간 지표로 해석하지 않는다. engagement_time을 임의 생성하지 않는다.
+- 브라우저 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 활성화
@@ -66,3 +66,11 @@ GA4와 같은 원장에서 HTTP V2 API로 전송한다. 별도 분석 서버나
- 현재 원장/전송 상태는 별도 자동 삭제 정책 없이 보관한다. 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 5e37495..f61d000 100644
--- a/docs/implementation-status.md
+++ b/docs/implementation-status.md
@@ -58,4 +58,4 @@
## 비즈니스 분석 확장 (2026-09-20)
-36종 이벤트 원장과 GA4·Amplitude 독립 전송 큐를 구현했다. 관리자 화면은 공급자별 설정·전송 상태를 표시한다. GA4 계정과 웹 스트림은 생성됐으며, 사용자 데이터 수집 확인과 두 서비스의 키 생성·연결 승인을 기다리고 있다. 이번 Amplitude 변경은 아직 배포 전이다. [연결 절차 및 한계](analytics-integrations.md)를 참고한다.
+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 e56f3da..878de7e 100644
--- a/docs/verification.md
+++ b/docs/verification.md
@@ -115,3 +115,12 @@ GitHub 업로드와 CI 통과를 운영 출시로 간주하지 않는다. 최신
- 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 적용을 확인했다. 이미 전송된 이벤트를 재전송하거나 이벤트 발생 시각을 변경하지 않았다.