mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Five fixes to the PostHog event stream, from an audit of what we actually collect versus what's flowing in. Each one is test-first. ## What changed **Silenced the `$opt_in` noise.** `initAnalytics` called `opt_in_capturing()` on every page load to clear a stale opt-out flag, and posthog-js emits an `$opt_in` event on every call. That was 10k+ events a month (up to 55 per user) carrying no signal: analytics is on by default with an admin opt-out, so there is no per-user consent to record. Both call sites now pass `captureEventName: false`. **Split the onboarding survey out of `feedback_submitted`.** The onboarding usage survey rode the same event as real feedback, so about 93% of "feedback" was actually onboarding profiling. It now emits `onboarding_survey_submitted`, so feedback metrics mean feedback again. **Set `pipeline_executed.file_count`.** It was declared in the properties interface but never populated. A pure `pipelineExecutedProps` helper now derives it (batch size for a batch run, else 1) and is shared by the success and failure paths, which also drops a duplicated payload. **Tracked OIDC login failures.** All six OIDC callback failure branches bumped the Prometheus counter and wrote an audit log but never emitted `auth_login_failed`. A `recordOidcFailure` helper mirrors the password path. **Added `TELEMETRY.md`.** A contributor-facing event dictionary: every event, its properties, where it fires, and the privacy invariants, with the allowlists as source of truth. A drift test fails if any `ANALYTICS_EVENTS` value goes undocumented. I left the published telemetry guide (`apps/docs/guide/telemetry.md`) alone. It is high-level and still accurate, and editing it would pull in the 21-locale stale-gate for no gain. ## Verification - Unit (63 tests): `analytics-events`, `telemetry-doc-drift`, `api/analytics`, `web/analytics`, `worker.behavior` - Integration (41 tests): `oidc-auth`, `feedback` - Full typecheck across all 9 workspaces - Biome clean on the changed files All green locally.
292 lines
8.2 KiB
TypeScript
292 lines
8.2 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const objectStorageMocks = vi.hoisted(() => ({
|
|
copyObjectToFile: vi.fn(),
|
|
getObjectBuffer: vi.fn(),
|
|
getObjectSize: vi.fn(),
|
|
putObject: vi.fn(),
|
|
}));
|
|
|
|
async function loadWorker() {
|
|
vi.resetModules();
|
|
|
|
vi.doMock("node:fs/promises", () => ({
|
|
mkdir: vi.fn(),
|
|
readFile: vi.fn(),
|
|
rm: vi.fn(),
|
|
}));
|
|
|
|
vi.doMock("@snapotter/shared", () => ({
|
|
ANALYTICS_EVENTS: {},
|
|
TOOLS: [],
|
|
getBundleForTool: vi.fn(() => null),
|
|
getOptionalBundleForTool: vi.fn(() => null),
|
|
}));
|
|
|
|
vi.doMock("bullmq", () => ({
|
|
UnrecoverableError: class UnrecoverableError extends Error {},
|
|
Worker: vi.fn(() => ({
|
|
on: vi.fn(),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
})),
|
|
}));
|
|
|
|
vi.doMock("drizzle-orm", () => ({
|
|
eq: vi.fn(() => "eq"),
|
|
}));
|
|
|
|
vi.doMock("../../../../apps/api/src/config.js", () => ({
|
|
env: {
|
|
SCRATCH_PATH: "",
|
|
JOB_TIMEOUT_LONG_S: 60,
|
|
JOB_TIMEOUT_FAST_S: 15,
|
|
},
|
|
}));
|
|
|
|
vi.doMock("../../../../apps/api/src/db/index.js", () => ({
|
|
db: {},
|
|
schema: { jobs: {} },
|
|
}));
|
|
|
|
vi.doMock("../../../../apps/api/src/lib/analytics.js", () => ({
|
|
captureException: vi.fn(),
|
|
trackEvent: vi.fn(),
|
|
}));
|
|
|
|
vi.doMock("../../../../apps/api/src/lib/analytics-gate.js", () => ({
|
|
analyticsEnabled: vi.fn(() => false),
|
|
}));
|
|
|
|
vi.doMock("../../../../apps/api/src/lib/env.js", () => ({
|
|
resolveConcurrency: vi.fn(() => 2),
|
|
}));
|
|
|
|
vi.doMock("../../../../apps/api/src/lib/errors.js", () => ({
|
|
friendlyError: vi.fn((message: string) => message),
|
|
}));
|
|
|
|
vi.doMock("../../../../apps/api/src/lib/logger.js", () => ({
|
|
logger: {
|
|
error: vi.fn(),
|
|
info: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.doMock("../../../../apps/api/src/lib/metrics.js", () => ({
|
|
jobDuration: { observe: vi.fn() },
|
|
jobsTotal: { inc: vi.fn() },
|
|
}));
|
|
|
|
vi.doMock("../../../../apps/api/src/lib/object-storage.js", () => ({
|
|
...objectStorageMocks,
|
|
}));
|
|
|
|
vi.doMock("../../../../apps/api/src/routes/progress.js", () => ({
|
|
publishEphemeral: vi.fn(),
|
|
updateSingleFileProgress: vi.fn(),
|
|
updateSingleFileProgressAtomically: vi.fn(),
|
|
}));
|
|
|
|
vi.doMock("../../../../apps/api/src/routes/tool-factory.js", () => ({
|
|
getToolConfig: vi.fn(),
|
|
}));
|
|
|
|
vi.doMock("../../../../apps/api/src/jobs/ai-handlers.js", () => ({
|
|
hasAiJobHandler: vi.fn(() => false),
|
|
runAiToolJob: vi.fn(),
|
|
}));
|
|
|
|
vi.doMock("../../../../apps/api/src/jobs/batch-progress.js", () => ({
|
|
recordChildOutcome: vi.fn(),
|
|
}));
|
|
|
|
vi.doMock("../../../../apps/api/src/jobs/cancel.js", () => ({
|
|
registerCancelable: vi.fn(() => new AbortController()),
|
|
unregisterCancelable: vi.fn(),
|
|
}));
|
|
|
|
vi.doMock("../../../../apps/api/src/jobs/connection.js", () => ({
|
|
createBullMQConnection: vi.fn(() => ({})),
|
|
}));
|
|
|
|
vi.doMock("../../../../apps/api/src/jobs/postprocess.js", () => ({
|
|
autoSaveToLibrary: vi.fn(),
|
|
buildOutputName: vi.fn(),
|
|
generatePreview: vi.fn(),
|
|
}));
|
|
|
|
vi.doMock("../../../../apps/api/src/jobs/system-jobs.js", () => ({
|
|
runSystemJob: vi.fn(),
|
|
}));
|
|
|
|
return import("../../../../apps/api/src/jobs/worker.js");
|
|
}
|
|
|
|
describe("worker result payload behavior", () => {
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("rejects an oversized OCR image object before buffering it", async () => {
|
|
objectStorageMocks.getObjectSize.mockResolvedValueOnce(512 * 1024 * 1024 + 1);
|
|
const { loadToolInputBuffer } = await loadWorker();
|
|
|
|
await expect(loadToolInputBuffer("ocr", "uploads/job-1/input.bin")).rejects.toMatchObject({
|
|
name: "InputValidationError",
|
|
statusCode: 413,
|
|
});
|
|
expect(objectStorageMocks.getObjectBuffer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("maps an oversized streamed OCR PDF object to the OCR input limit", async () => {
|
|
objectStorageMocks.copyObjectToFile.mockRejectedValueOnce(
|
|
Object.assign(new Error("too large"), { statusCode: 413 }),
|
|
);
|
|
const { loadToolInputs } = await loadWorker();
|
|
|
|
await expect(
|
|
loadToolInputs(
|
|
"ocr-pdf",
|
|
["uploads/job-1/scan.pdf"],
|
|
"scan.pdf",
|
|
"/tmp/job-1",
|
|
new AbortController().signal,
|
|
),
|
|
).rejects.toMatchObject({ name: "InputValidationError", statusCode: 413 });
|
|
expect(objectStorageMocks.getObjectBuffer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("loads OCR PDF input as a bounded scratch path without buffering it", async () => {
|
|
objectStorageMocks.copyObjectToFile.mockResolvedValueOnce(42);
|
|
const controller = new AbortController();
|
|
const { loadToolInputs } = await loadWorker();
|
|
|
|
await expect(
|
|
loadToolInputs(
|
|
"ocr-pdf",
|
|
["uploads/job-1/scan.pdf"],
|
|
"scan.pdf",
|
|
"/tmp/job-1",
|
|
controller.signal,
|
|
),
|
|
).resolves.toEqual({
|
|
inputs: [],
|
|
pathInput: { path: "/tmp/job-1/input.pdf", size: 42 },
|
|
originalSize: 42,
|
|
});
|
|
|
|
expect(objectStorageMocks.copyObjectToFile).toHaveBeenCalledWith(
|
|
"uploads/job-1/scan.pdf",
|
|
"/tmp/job-1/input.pdf",
|
|
{
|
|
maxBytes: 512 * 1024 * 1024,
|
|
signal: controller.signal,
|
|
},
|
|
);
|
|
expect(objectStorageMocks.getObjectBuffer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("loads OCR objects at the encoded-size boundary and leaves other tools unchanged", async () => {
|
|
objectStorageMocks.getObjectSize.mockResolvedValueOnce(512 * 1024 * 1024);
|
|
objectStorageMocks.getObjectBuffer.mockResolvedValue(Buffer.from("ocr"));
|
|
const { loadToolInputBuffer } = await loadWorker();
|
|
|
|
await expect(loadToolInputBuffer("ocr", "uploads/job-1/scan.tiff")).resolves.toEqual(
|
|
Buffer.from("ocr"),
|
|
);
|
|
await expect(loadToolInputBuffer("compress", "uploads/job-2/photo.png")).resolves.toEqual(
|
|
Buffer.from("ocr"),
|
|
);
|
|
expect(objectStorageMocks.getObjectSize).toHaveBeenCalledTimes(1);
|
|
expect(objectStorageMocks.getObjectBuffer).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("builds legacy download, preview, saved-file, and tool payload fields", async () => {
|
|
const { buildLegacyResultPayload } = await loadWorker();
|
|
|
|
expect(
|
|
buildLegacyResultPayload(
|
|
{
|
|
outputRefs: ["outputs/job-1/report final.pdf"],
|
|
filename: "report final.pdf",
|
|
contentType: "application/pdf",
|
|
originalSize: 100,
|
|
processedSize: 80,
|
|
previewRef: "outputs/job-1/preview.png",
|
|
savedFileId: "file-2",
|
|
resultPayload: { pageCount: 3 },
|
|
},
|
|
"job-1",
|
|
),
|
|
).toEqual({
|
|
jobId: "job-1",
|
|
downloadUrl: "/api/v1/download/job-1/report%20final.pdf",
|
|
previewUrl: "/api/v1/download/job-1/preview.png",
|
|
originalSize: 100,
|
|
processedSize: 80,
|
|
savedFileId: "file-2",
|
|
pageCount: 3,
|
|
});
|
|
});
|
|
|
|
it("omits optional legacy payload fields when the job result does not include them", async () => {
|
|
const { buildLegacyResultPayload } = await loadWorker();
|
|
|
|
expect(
|
|
buildLegacyResultPayload(
|
|
{
|
|
outputRefs: ["outputs/job-2/out.png"],
|
|
filename: "out.png",
|
|
contentType: "image/png",
|
|
originalSize: 10,
|
|
processedSize: 8,
|
|
},
|
|
"job-2",
|
|
),
|
|
).toEqual({
|
|
jobId: "job-2",
|
|
downloadUrl: "/api/v1/download/job-2/out.png",
|
|
originalSize: 10,
|
|
processedSize: 8,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("pipelineExecutedProps", () => {
|
|
it("reports the batch file count for a batch-finalize pipeline", async () => {
|
|
const { pipelineExecutedProps } = await loadWorker();
|
|
|
|
expect(
|
|
pipelineExecutedProps(
|
|
{ kind: "batch-finalize", totalFiles: 5 },
|
|
3,
|
|
["resize", "compress", "watermark"],
|
|
1200,
|
|
"completed",
|
|
),
|
|
).toEqual({
|
|
step_count: 3,
|
|
tool_ids: ["resize", "compress", "watermark"],
|
|
is_batch: true,
|
|
file_count: 5,
|
|
duration_ms: 1200,
|
|
status: "completed",
|
|
});
|
|
});
|
|
|
|
it("defaults file_count to 1 for a single-file pipeline-finalize", async () => {
|
|
const { pipelineExecutedProps } = await loadWorker();
|
|
|
|
expect(
|
|
pipelineExecutedProps(
|
|
{ kind: "pipeline-finalize" },
|
|
2,
|
|
["grayscale", "resize"],
|
|
800,
|
|
"failed",
|
|
),
|
|
).toMatchObject({ is_batch: false, file_count: 1, status: "failed" });
|
|
});
|
|
});
|