mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-06 02:39:57 +02:00
Merge pull request #14 from vnidrop/feat/diagnostics-telemetry
feat(diagnostics): ship production telemetry and bug reporting
This commit is contained in:
53
.github/workflows/diagnostics-api.yml
vendored
Normal file
53
.github/workflows/diagnostics-api.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Diagnostics API
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "services/diagnostics-api/**"
|
||||
- ".github/workflows/diagnostics-api.yml"
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "services/diagnostics-api/**"
|
||||
- ".github/workflows/diagnostics-api.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: diagnostics-api-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: services/diagnostics-api
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: services/diagnostics-api/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Verify generated Worker types
|
||||
run: npm run types:check
|
||||
|
||||
- name: Type-check
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test in the Workers runtime
|
||||
run: npm test
|
||||
|
||||
- name: Validate the deployment bundle
|
||||
run: npm run deploy:dry-run
|
||||
5
.github/workflows/shared-kmp.yml
vendored
5
.github/workflows/shared-kmp.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin,aarch64-linux-android
|
||||
targets: aarch64-apple-darwin,aarch64-linux-android,x86_64-linux-android
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
@@ -96,3 +96,6 @@ jobs:
|
||||
|
||||
- name: Run shared JVM tests
|
||||
run: ./gradlew :shared:jvmTest --no-daemon --stacktrace
|
||||
|
||||
- name: Verify Android native libraries
|
||||
run: ./gradlew :androidApp:verifyDebugVnidropLibraries --no-daemon --stacktrace
|
||||
|
||||
@@ -1,4 +1,34 @@
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.file.RegularFileProperty
|
||||
import org.gradle.api.provider.SetProperty
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.InputFile
|
||||
import org.gradle.api.tasks.PathSensitive
|
||||
import org.gradle.api.tasks.PathSensitivity
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
abstract class VerifyVnidropLibrariesTask : DefaultTask() {
|
||||
@get:InputFile
|
||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract val apk: RegularFileProperty
|
||||
|
||||
@get:Input
|
||||
abstract val requiredLibraries: SetProperty<String>
|
||||
|
||||
@TaskAction
|
||||
fun verify() {
|
||||
ZipFile(apk.get().asFile).use { archive ->
|
||||
val missing = requiredLibraries.get().filter { path ->
|
||||
archive.getEntry(path)?.size?.takeIf { it > 0L } == null
|
||||
}
|
||||
check(missing.isEmpty()) {
|
||||
"Debug APK has missing or empty VniDrop libraries: ${missing.joinToString()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.androidApplication)
|
||||
@@ -37,7 +67,10 @@ android {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
jniLibs {
|
||||
pickFirsts += "lib/arm64-v8a/libvnidrop.so"
|
||||
pickFirsts += setOf(
|
||||
"lib/arm64-v8a/libvnidrop.so",
|
||||
"lib/x86_64/libvnidrop.so",
|
||||
)
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
@@ -52,12 +85,33 @@ android {
|
||||
sourceSets {
|
||||
getByName("debug") {
|
||||
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/aarch64-linux-android/debug"))
|
||||
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/x86_64-linux-android/debug"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.configureEach {
|
||||
if (name == "mergeDebugJniLibFolders" || name == "mergeDebugNativeLibs") {
|
||||
dependsOn(":shared:copyAndroidAndroidArm64Debug")
|
||||
dependsOn(
|
||||
":shared:copyAndroidAndroidArm64Debug",
|
||||
":shared:copyAndroidAndroidX64Debug",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val verifyDebugVnidropLibraries = tasks.register<VerifyVnidropLibrariesTask>("verifyDebugVnidropLibraries") {
|
||||
group = "verification"
|
||||
description = "Verifies that the debug APK packages VniDrop for every supported Android ABI."
|
||||
dependsOn("assembleDebug")
|
||||
apk.set(layout.buildDirectory.file("outputs/apk/debug/androidApp-debug.apk"))
|
||||
requiredLibraries.set(
|
||||
setOf(
|
||||
"lib/arm64-v8a/libvnidrop.so",
|
||||
"lib/x86_64/libvnidrop.so",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
tasks.named("check") {
|
||||
dependsOn(verifyDebugVnidropLibraries)
|
||||
}
|
||||
|
||||
@@ -12,3 +12,14 @@ android.newDsl=false
|
||||
android.nonTransitiveRClass=true
|
||||
android.sourceset.disallowProvider=false
|
||||
android.useAndroidX=true
|
||||
|
||||
# VniDrop: compile-time diagnostics/telemetry product surface.
|
||||
# false → no Share-diagnostics toggle, no telemetry or crash auto-upload stack.
|
||||
# Bug report UI remains available (user-initiated).
|
||||
# Override per build: ./gradlew … -Pvnidrop.diagnostics.included=false
|
||||
vnidrop.diagnostics.included=true
|
||||
# Cloudflare Worker base URL (no trailing slash). Both endpoint/key empty → NoOp transport.
|
||||
# Example: https://vnidrop-diagnostics.<subdomain>.workers.dev
|
||||
vnidrop.diagnostics.endpoint=
|
||||
# Shared ingest key (must match Worker secret INGEST_KEY). Keep blank in VCS; use user-level/CI properties.
|
||||
vnidrop.diagnostics.ingestKey=
|
||||
|
||||
6
services/diagnostics-api/.gitignore
vendored
Normal file
6
services/diagnostics-api/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
dist/
|
||||
.dev.vars*
|
||||
.env*
|
||||
*.log
|
||||
196
services/diagnostics-api/README.md
Normal file
196
services/diagnostics-api/README.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# VniDrop diagnostics API
|
||||
|
||||
Cloudflare Worker for ingesting batched telemetry, crash reports, and user-submitted
|
||||
bug reports. D1 stores searchable metadata; R2 stores larger stack traces and logs.
|
||||
|
||||
The service is designed for modest traffic and low operating cost:
|
||||
|
||||
- one D1 row is written per telemetry batch, not per event;
|
||||
- crash stacks and bug logs are stored in R2 instead of D1;
|
||||
- request and batch limits reject oversized work before storage writes;
|
||||
- an hourly scheduled cleanup and an R2 lifecycle rule enforce retention;
|
||||
- no Queue, Durable Object, or KV resources are required.
|
||||
|
||||
Cloudflare quotas and prices change over time. Check the current
|
||||
[Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/),
|
||||
[D1 pricing](https://developers.cloudflare.com/d1/platform/pricing/), and
|
||||
[R2 pricing](https://developers.cloudflare.com/r2/pricing/) before relying on a
|
||||
particular free-plan capacity.
|
||||
|
||||
## API
|
||||
|
||||
All ingest routes require:
|
||||
|
||||
```http
|
||||
X-VniDrop-Key: <INGEST_KEY>
|
||||
```
|
||||
|
||||
The client also sends its anonymous installation ID:
|
||||
|
||||
```http
|
||||
X-VniDrop-Install-Id: <anonymous install UUID>
|
||||
```
|
||||
|
||||
| Method | Path | Body |
|
||||
|--------|------|------|
|
||||
| `GET` | `/live` | process liveness; does not touch storage |
|
||||
| `GET` | `/health` | authenticated readiness; checks required configuration and the D1 schema |
|
||||
| `POST` | `/v1/events` | `{ batchId, installId, appVersion?, platform?, events: [...] }` |
|
||||
| `POST` | `/v1/crashes` | app crash payload |
|
||||
| `POST` | `/v1/bugs` | app bug-report payload |
|
||||
|
||||
Batch and report IDs are client-generated UUIDs. A client must reuse the same ID
|
||||
when retrying so D1 can acknowledge the request without storing it twice.
|
||||
|
||||
Accepted reports return `202`. Defaults are a 262,144-byte request limit and at
|
||||
most 50 events per batch. Cloudflare rate-limit bindings allow 30 requests per
|
||||
installation and 120 requests per source, per ingest route, per minute. Source
|
||||
limits run before shared-key verification so rejected traffic is bounded too.
|
||||
These counters are eventually consistent and local to a Cloudflare location, so
|
||||
they are abuse mitigation rather than billing or authorization controls.
|
||||
|
||||
The API is consumed by native Android, iOS, and desktop clients and does not
|
||||
enable cross-origin browser access. If a browser-based client is added later,
|
||||
define a narrow origin allowlist instead of enabling wildcard CORS.
|
||||
|
||||
`/health` requires `X-VniDrop-Key` and uses the source limiter because it performs
|
||||
D1 reads. `/live` is the only unauthenticated probe and never touches storage.
|
||||
|
||||
## Security model
|
||||
|
||||
`INGEST_KEY` fails closed when it is missing, but it is a shared value embedded in
|
||||
released app binaries. It can be extracted and therefore is **not** user
|
||||
authentication, a durable secret, or sufficient abuse protection by itself.
|
||||
|
||||
- Store the Worker value with `wrangler secret put`; never put it in
|
||||
`wrangler.jsonc`, source control, logs, or command arguments.
|
||||
- Rotate the key when it is exposed and ship the matching app configuration.
|
||||
- Keep the two rate-limit namespaces unique within the Cloudflare account. A
|
||||
namespace reused by another Worker shares counters with it.
|
||||
- Use Cloudflare WAF or account-level rate-limiting rules if public abuse exceeds
|
||||
what the Worker bindings can absorb.
|
||||
- Do not log request bodies. Bug reports can contain contact details and attached
|
||||
logs.
|
||||
|
||||
## Provision and deploy
|
||||
|
||||
Run these commands from this directory:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npx wrangler login
|
||||
|
||||
npx wrangler d1 create vnidrop-diagnostics
|
||||
npx wrangler r2 bucket create vnidrop-diagnostics
|
||||
```
|
||||
|
||||
Replace the placeholder `database_id` in `wrangler.jsonc` with the UUID returned
|
||||
by `wrangler d1 create`. Set the ingest key interactively, apply the tracked D1
|
||||
migrations, and configure the R2 retention rule once:
|
||||
|
||||
```bash
|
||||
npx wrangler secret put INGEST_KEY
|
||||
npm run db:migrate:remote
|
||||
npx wrangler r2 bucket lifecycle add vnidrop-diagnostics diagnostics-retention --expire-days 90
|
||||
npm run check
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
`npm run deploy` also runs the complete `check` script automatically before
|
||||
Wrangler changes the remote Worker.
|
||||
|
||||
The lifecycle command changes the remote bucket. Before adding or changing a
|
||||
rule, inspect the current state with:
|
||||
|
||||
```bash
|
||||
npx wrangler r2 bucket lifecycle list vnidrop-diagnostics
|
||||
```
|
||||
|
||||
## Local development
|
||||
|
||||
Create an ignored `.dev.vars` file containing a development-only key:
|
||||
|
||||
```dotenv
|
||||
INGEST_KEY=local-development-only
|
||||
```
|
||||
|
||||
Then initialize the local D1 database and run the Worker:
|
||||
|
||||
```bash
|
||||
npm run db:migrate:local
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Wrangler keeps local D1 and R2 state under the ignored `.wrangler/` directory.
|
||||
Use `wrangler dev --test-scheduled` when exercising the hourly cleanup handler.
|
||||
|
||||
## Migrations and generated types
|
||||
|
||||
D1 migrations live in `migrations/` and are recorded in D1's migration ledger.
|
||||
Never edit an applied migration; add the next numbered SQL file instead.
|
||||
|
||||
`worker-configuration.d.ts` is generated from `wrangler.jsonc` and committed so
|
||||
bindings cannot silently drift from the Worker code:
|
||||
|
||||
```bash
|
||||
npm run typegen # regenerate after changing bindings or vars
|
||||
npm run types:check # verify the committed file is current
|
||||
```
|
||||
|
||||
Secrets and optional, commented-out bindings are not generated. The source adds
|
||||
only those narrow extensions to the generated environment type.
|
||||
|
||||
Vitest runs inside the Workers runtime. Its setup applies the same numbered D1
|
||||
migrations to the isolated local database assigned to each test file.
|
||||
|
||||
## Retention
|
||||
|
||||
`RETENTION_DAYS` defaults to 90. The `17 * * * *` cron trigger runs cleanup at
|
||||
17 minutes past every hour. Cleanup works in bounded batches: it deletes each
|
||||
expired report's referenced R2 object before deleting that exact D1 row. The R2
|
||||
lifecycle rule is an independent backstop for stack and log objects, including
|
||||
objects left behind by a partial ingest failure. Each scheduled run can remove
|
||||
8,000 event batches and 7,200 rows from each report table while staying below
|
||||
D1's per-invocation query ceiling. Later hourly runs continue any backlog.
|
||||
Reaching the cap emits a structured warning with the remaining expired-row counts;
|
||||
alert on that warning because
|
||||
retention is necessarily best-effort during sustained distributed abuse.
|
||||
|
||||
The Worker variable and bucket lifecycle are separate configuration surfaces.
|
||||
When changing retention, update both `RETENTION_DAYS` and the R2 lifecycle rule;
|
||||
changing one does not update the other. Cloudflare may delete expired R2 objects
|
||||
after the exact expiration time rather than synchronously at it.
|
||||
|
||||
## App wiring
|
||||
|
||||
Keep the tracked root defaults empty. Configure release builds through the
|
||||
user-level `~/.gradle/gradle.properties` or secured CI Gradle project properties:
|
||||
|
||||
```properties
|
||||
vnidrop.diagnostics.included=true
|
||||
vnidrop.diagnostics.endpoint=https://vnidrop-diagnostics.<your-subdomain>.workers.dev
|
||||
vnidrop.diagnostics.ingestKey=<same value as INGEST_KEY>
|
||||
```
|
||||
|
||||
Both the endpoint and key are required. When both are empty the app uses its
|
||||
offline-safe no-op transport; configuring only one fails the Gradle build.
|
||||
`vnidrop.diagnostics.included=false` disables
|
||||
automatic telemetry and crash upload, but a configured endpoint can still accept
|
||||
an explicit user-submitted bug report. Treat the app-side key as an abuse-control
|
||||
token with the limitations described above.
|
||||
|
||||
## Reading reports
|
||||
|
||||
```bash
|
||||
npx wrangler d1 execute vnidrop-diagnostics --remote \
|
||||
--command "SELECT id, exception_type, platform, occurred_at FROM crashes ORDER BY occurred_at DESC LIMIT 20"
|
||||
|
||||
npx wrangler d1 execute vnidrop-diagnostics --remote \
|
||||
--command "SELECT id, what_happened, status, occurred_at FROM bugs WHERE status = 'open' ORDER BY occurred_at DESC LIMIT 20"
|
||||
```
|
||||
|
||||
R2 object keys use `crashes/<id>/<attempt-id>/stack.txt` and
|
||||
`bugs/<id>/<attempt-id>/logs.txt`. The unique attempt segment prevents a retry
|
||||
from overwriting an already accepted object before D1 detects the duplicate.
|
||||
There is no public administration endpoint; inspect reports through authenticated
|
||||
Cloudflare tools or a future Access-protected dashboard.
|
||||
55
services/diagnostics-api/migrations/0001_initial.sql
Normal file
55
services/diagnostics-api/migrations/0001_initial.sql
Normal file
@@ -0,0 +1,55 @@
|
||||
-- This migration also baselines databases previously initialized by schema.sql.
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_batches (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
received_at INTEGER NOT NULL,
|
||||
install_id TEXT NOT NULL,
|
||||
app_version TEXT,
|
||||
platform TEXT,
|
||||
event_count INTEGER NOT NULL,
|
||||
payload_json TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_event_batches_received ON event_batches (received_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_batches_install ON event_batches (install_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crashes (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
received_at INTEGER NOT NULL,
|
||||
occurred_at INTEGER NOT NULL,
|
||||
install_id TEXT NOT NULL,
|
||||
app_version TEXT,
|
||||
platform TEXT,
|
||||
exception_type TEXT,
|
||||
exception_message TEXT,
|
||||
fingerprint TEXT NOT NULL,
|
||||
diagnostics_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
stack_r2_key TEXT,
|
||||
breadcrumbs_json TEXT,
|
||||
schema_version INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crashes_received ON crashes (received_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_crashes_fingerprint ON crashes (fingerprint);
|
||||
CREATE INDEX IF NOT EXISTS idx_crashes_install ON crashes (install_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bugs (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
received_at INTEGER NOT NULL,
|
||||
install_id TEXT NOT NULL,
|
||||
app_version TEXT,
|
||||
platform TEXT,
|
||||
what_happened TEXT NOT NULL,
|
||||
expected TEXT NOT NULL,
|
||||
steps TEXT,
|
||||
contact TEXT,
|
||||
logs_r2_key TEXT,
|
||||
device_json TEXT,
|
||||
breadcrumbs_json TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
schema_version INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bugs_received ON bugs (received_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_bugs_status ON bugs (status);
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE bugs ADD COLUMN occurred_at INTEGER;
|
||||
|
||||
-- Existing reports predate this field; their receipt time is the best available value.
|
||||
UPDATE bugs SET occurred_at = received_at WHERE occurred_at IS NULL;
|
||||
3151
services/diagnostics-api/package-lock.json
generated
Normal file
3151
services/diagnostics-api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
services/diagnostics-api/package.json
Normal file
30
services/diagnostics-api/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "vnidrop-diagnostics-api",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.12"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"predeploy": "npm run check",
|
||||
"deploy": "wrangler deploy",
|
||||
"deploy:dry-run": "wrangler deploy --dry-run",
|
||||
"db:migrate:local": "wrangler d1 migrations apply vnidrop-diagnostics --local",
|
||||
"db:migrate:remote": "wrangler d1 migrations apply vnidrop-diagnostics --remote",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typegen": "wrangler types",
|
||||
"types:check": "wrangler types --check",
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p test/tsconfig.json",
|
||||
"check": "npm run types:check && npm run typecheck && npm test && npm run deploy:dry-run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.18.4",
|
||||
"@types/node": "^26.1.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10",
|
||||
"wrangler": "^4.110.0"
|
||||
}
|
||||
}
|
||||
267
services/diagnostics-api/src/index.ts
Normal file
267
services/diagnostics-api/src/index.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
normalizeBug,
|
||||
normalizeCrash,
|
||||
normalizeEvents,
|
||||
readJsonObject,
|
||||
} from "./input";
|
||||
import {
|
||||
type DiagnosticsEnv,
|
||||
runRetention,
|
||||
storeBug,
|
||||
storeCrash,
|
||||
storeEvents,
|
||||
} from "./storage";
|
||||
|
||||
const DEFAULT_MAX_BODY_BYTES = 262_144;
|
||||
const HARD_MAX_BODY_BYTES = 1_048_576;
|
||||
const DEFAULT_MAX_EVENTS = 50;
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: DiagnosticsEnv, _ctx: ExecutionContext): Promise<Response> {
|
||||
const requestId = crypto.randomUUID();
|
||||
const url = new URL(request.url);
|
||||
try {
|
||||
if (request.method === "GET" && url.pathname === "/live") {
|
||||
return json({ ok: true, service: "vnidrop-diagnostics", schema: 1 }, 200, requestId);
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/health") {
|
||||
if (await sourceRateLimited(request, url.pathname, env)) {
|
||||
return json({ error: "rate_limited" }, 429, requestId, { "retry-after": "60" });
|
||||
}
|
||||
const authError = await authorize(request, env);
|
||||
if (authError) return json({ error: authError.error }, authError.status, requestId);
|
||||
return await readiness(env, requestId);
|
||||
}
|
||||
|
||||
if (!isIngestPath(url.pathname)) {
|
||||
return json({ error: "not_found" }, 404, requestId);
|
||||
}
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: responseHeaders(requestId, { allow: "POST, OPTIONS" }),
|
||||
});
|
||||
}
|
||||
if (request.method !== "POST") {
|
||||
return json(
|
||||
{ error: "method_not_allowed" },
|
||||
405,
|
||||
requestId,
|
||||
{ allow: "POST, OPTIONS" },
|
||||
);
|
||||
}
|
||||
|
||||
if (await sourceRateLimited(request, url.pathname, env)) {
|
||||
return json({ error: "rate_limited" }, 429, requestId, { "retry-after": "60" });
|
||||
}
|
||||
|
||||
const authError = await authorize(request, env);
|
||||
if (authError) return json({ error: authError.error }, authError.status, requestId);
|
||||
|
||||
if (await installRateLimited(request, url.pathname, env)) {
|
||||
return json({ error: "rate_limited" }, 429, requestId, { "retry-after": "60" });
|
||||
}
|
||||
|
||||
const maxBodyBytes = boundedPositiveInt(
|
||||
env.MAX_BODY_BYTES,
|
||||
DEFAULT_MAX_BODY_BYTES,
|
||||
1,
|
||||
HARD_MAX_BODY_BYTES,
|
||||
);
|
||||
const parsed = await readJsonObject(request, maxBodyBytes);
|
||||
if (!parsed.ok) return json({ error: parsed.error }, parsed.status, requestId);
|
||||
|
||||
switch (url.pathname) {
|
||||
case "/v1/events": {
|
||||
const maxEvents = boundedPositiveInt(env.MAX_EVENTS_PER_BATCH, DEFAULT_MAX_EVENTS, 1, 100);
|
||||
const normalized = normalizeEvents(parsed.value, maxEvents);
|
||||
if (!normalized.ok) {
|
||||
return json({ error: normalized.error }, normalized.status, requestId);
|
||||
}
|
||||
const result = await storeEvents(normalized.value, env);
|
||||
return json(
|
||||
{
|
||||
ok: true,
|
||||
id: result.id,
|
||||
stored: result.stored,
|
||||
duplicate: result.duplicate,
|
||||
},
|
||||
202,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
case "/v1/crashes": {
|
||||
const normalized = normalizeCrash(parsed.value);
|
||||
if (!normalized.ok) {
|
||||
return json({ error: normalized.error }, normalized.status, requestId);
|
||||
}
|
||||
const result = await storeCrash(normalized.value, env);
|
||||
return json(
|
||||
{
|
||||
ok: true,
|
||||
id: result.id,
|
||||
fingerprint: result.fingerprint,
|
||||
duplicate: result.duplicate,
|
||||
},
|
||||
202,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
case "/v1/bugs": {
|
||||
const normalized = normalizeBug(parsed.value);
|
||||
if (!normalized.ok) {
|
||||
return json({ error: normalized.error }, normalized.status, requestId);
|
||||
}
|
||||
const result = await storeBug(normalized.value, env);
|
||||
return json(
|
||||
{ ok: true, id: result.id, duplicate: result.duplicate },
|
||||
202,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: "diagnostics request failed",
|
||||
requestId,
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
return json({ error: "internal" }, 500, requestId);
|
||||
}
|
||||
},
|
||||
|
||||
scheduled(
|
||||
controller: ScheduledController,
|
||||
env: DiagnosticsEnv,
|
||||
ctx: ExecutionContext,
|
||||
): void {
|
||||
ctx.waitUntil(
|
||||
runRetention(env).catch((error) => {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: "diagnostics retention failed",
|
||||
scheduledTime: controller.scheduledTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
} satisfies ExportedHandler<DiagnosticsEnv>;
|
||||
|
||||
async function readiness(env: DiagnosticsEnv, requestId: string): Promise<Response> {
|
||||
if (!env.INGEST_KEY) {
|
||||
return json({ ok: false, error: "server_misconfigured" }, 503, requestId);
|
||||
}
|
||||
try {
|
||||
await env.DB.batch([
|
||||
env.DB.prepare(
|
||||
"SELECT id, received_at, install_id, payload_json FROM event_batches LIMIT 1",
|
||||
),
|
||||
env.DB.prepare(
|
||||
"SELECT id, occurred_at, stack_r2_key, breadcrumbs_json FROM crashes LIMIT 1",
|
||||
),
|
||||
env.DB.prepare(
|
||||
"SELECT id, occurred_at, logs_r2_key, device_json FROM bugs LIMIT 1",
|
||||
),
|
||||
]);
|
||||
return json({ ok: true, service: "vnidrop-diagnostics", schema: 1 }, 200, requestId);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: "diagnostics readiness check failed",
|
||||
requestId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
return json({ ok: false, error: "dependency_unavailable" }, 503, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
async function authorize(
|
||||
request: Request,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<{ status: 401 | 503; error: "unauthorized" | "server_misconfigured" } | null> {
|
||||
const expected = env.INGEST_KEY;
|
||||
if (!expected) return { status: 503, error: "server_misconfigured" };
|
||||
const provided = request.headers.get("x-vnidrop-key") ?? "";
|
||||
if (!(await timingSafeEqual(provided, expected))) {
|
||||
return { status: 401, error: "unauthorized" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function sourceRateLimited(
|
||||
request: Request,
|
||||
path: string,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<boolean> {
|
||||
const source = request.headers.get("cf-connecting-ip")?.slice(0, 64) || "unknown";
|
||||
const result = await env.SOURCE_RATE_LIMITER.limit({ key: `${source}:${path}` });
|
||||
return !result.success;
|
||||
}
|
||||
|
||||
async function installRateLimited(
|
||||
request: Request,
|
||||
path: string,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<boolean> {
|
||||
const source = request.headers.get("cf-connecting-ip")?.slice(0, 64) || "unknown";
|
||||
const installId = request.headers.get("x-vnidrop-install-id")?.trim().slice(0, 80) || source;
|
||||
const result = await env.INSTALL_RATE_LIMITER.limit({ key: `${installId}:${path}` });
|
||||
return !result.success;
|
||||
}
|
||||
|
||||
function isIngestPath(path: string): path is "/v1/events" | "/v1/crashes" | "/v1/bugs" {
|
||||
return path === "/v1/events" || path === "/v1/crashes" || path === "/v1/bugs";
|
||||
}
|
||||
|
||||
async function timingSafeEqual(provided: string, expected: string): Promise<boolean> {
|
||||
const encoder = new TextEncoder();
|
||||
const [providedHash, expectedHash] = await Promise.all([
|
||||
crypto.subtle.digest("SHA-256", encoder.encode(provided)),
|
||||
crypto.subtle.digest("SHA-256", encoder.encode(expected)),
|
||||
]);
|
||||
return crypto.subtle.timingSafeEqual(providedHash, expectedHash);
|
||||
}
|
||||
|
||||
function json(
|
||||
body: unknown,
|
||||
status: number,
|
||||
requestId: string,
|
||||
extraHeaders?: Readonly<Record<string, string>>,
|
||||
): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: responseHeaders(requestId, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
...extraHeaders,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function responseHeaders(
|
||||
requestId: string,
|
||||
extraHeaders?: Readonly<Record<string, string>>,
|
||||
): Headers {
|
||||
const headers = new Headers(extraHeaders);
|
||||
headers.set("cache-control", "no-store");
|
||||
headers.set("x-content-type-options", "nosniff");
|
||||
headers.set("x-request-id", requestId);
|
||||
return headers;
|
||||
}
|
||||
|
||||
function boundedPositiveInt(
|
||||
raw: string | undefined,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
593
services/diagnostics-api/src/input.ts
Normal file
593
services/diagnostics-api/src/input.ts
Normal file
@@ -0,0 +1,593 @@
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
export type InputFailure = {
|
||||
ok: false;
|
||||
status: 400 | 413 | 415;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type InputResult<T> = { ok: true; value: T } | InputFailure;
|
||||
|
||||
export type NormalizedProperties = Record<string, string>;
|
||||
|
||||
export interface NormalizedEvent {
|
||||
name: string;
|
||||
timestampMillis: number;
|
||||
properties: NormalizedProperties;
|
||||
schemaVersion: 1;
|
||||
}
|
||||
|
||||
export interface NormalizedBreadcrumb {
|
||||
name: string;
|
||||
timestampMillis: number;
|
||||
properties: NormalizedProperties;
|
||||
}
|
||||
|
||||
export interface NormalizedDevice {
|
||||
deviceName: string;
|
||||
deviceModel: string;
|
||||
operatingSystem: string;
|
||||
network: string;
|
||||
batteryLevel: string;
|
||||
}
|
||||
|
||||
export interface NormalizedEventsPayload {
|
||||
batchId: string;
|
||||
installId: string;
|
||||
appVersion: string;
|
||||
platform: string;
|
||||
events: NormalizedEvent[];
|
||||
}
|
||||
|
||||
export interface NormalizedCrashPayload {
|
||||
id: string;
|
||||
installId: string;
|
||||
appVersion: string;
|
||||
platform: string;
|
||||
exceptionType: string;
|
||||
exceptionMessage: string;
|
||||
stackTrace: string;
|
||||
occurredAt: number;
|
||||
diagnosticsEnabledAtCapture: boolean;
|
||||
breadcrumbs: NormalizedBreadcrumb[];
|
||||
schemaVersion: 1;
|
||||
}
|
||||
|
||||
export interface NormalizedBugPayload {
|
||||
id: string;
|
||||
installId: string;
|
||||
appVersion: string;
|
||||
platform: string;
|
||||
occurredAt: number;
|
||||
whatHappened: string;
|
||||
expected: string;
|
||||
steps: string;
|
||||
contact: string;
|
||||
logs: string;
|
||||
device: NormalizedDevice;
|
||||
breadcrumbs: NormalizedBreadcrumb[];
|
||||
schemaVersion: 1;
|
||||
}
|
||||
|
||||
export const MAX_LOG_BYTES = 192 * 1024;
|
||||
export const MAX_BREADCRUMBS_JSON_BYTES = 16_000;
|
||||
export const MAX_DEVICE_JSON_BYTES = 4_000;
|
||||
|
||||
const MAX_PROPERTIES = 12;
|
||||
const MAX_BREADCRUMBS = 40;
|
||||
const MISSING = Symbol("missing");
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const UTF8_ENCODER = new TextEncoder();
|
||||
|
||||
export async function readJsonObject(
|
||||
request: Request,
|
||||
maxBytes: number,
|
||||
): Promise<InputResult<JsonObject>> {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
||||
throw new RangeError("maxBytes must be a positive safe integer");
|
||||
}
|
||||
|
||||
if (!isApplicationJson(request.headers.get("content-type"))) {
|
||||
return failure(415, "unsupported_media_type");
|
||||
}
|
||||
|
||||
const contentLength = request.headers.get("content-length");
|
||||
if (contentLength != null) {
|
||||
const trimmed = contentLength.trim();
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
return failure(400, "invalid_content_length");
|
||||
}
|
||||
const normalizedLength = trimmed.replace(/^0+(?=\d)/, "");
|
||||
const maxLength = String(maxBytes);
|
||||
if (
|
||||
normalizedLength.length > maxLength.length ||
|
||||
(normalizedLength.length === maxLength.length && normalizedLength > maxLength)
|
||||
) {
|
||||
return failure(413, "payload_too_large");
|
||||
}
|
||||
const declaredBytes = Number(trimmed);
|
||||
if (!Number.isSafeInteger(declaredBytes)) {
|
||||
return failure(400, "invalid_content_length");
|
||||
}
|
||||
}
|
||||
|
||||
if (request.body == null) {
|
||||
return failure(400, "invalid_json");
|
||||
}
|
||||
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array>;
|
||||
try {
|
||||
reader = request.body.getReader();
|
||||
} catch {
|
||||
return failure(400, "invalid_body");
|
||||
}
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
if (chunk.value.byteLength > maxBytes - totalBytes) {
|
||||
try {
|
||||
await reader.cancel("payload_too_large");
|
||||
} catch {
|
||||
// The size failure remains authoritative if cancellation also fails.
|
||||
}
|
||||
return failure(413, "payload_too_large");
|
||||
}
|
||||
totalBytes += chunk.value.byteLength;
|
||||
chunks.push(chunk.value);
|
||||
}
|
||||
} catch {
|
||||
return failure(400, "invalid_body");
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const bytes = joinChunks(chunks, totalBytes);
|
||||
let raw: string;
|
||||
try {
|
||||
raw = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
|
||||
} catch {
|
||||
return failure(400, "invalid_utf8");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return failure(400, "invalid_json");
|
||||
}
|
||||
if (!isPlainObject(parsed)) {
|
||||
return failure(400, "invalid_body");
|
||||
}
|
||||
return success(parsed);
|
||||
}
|
||||
|
||||
export function normalizeEvents(
|
||||
body: JsonObject,
|
||||
maxEvents = 50,
|
||||
): InputResult<NormalizedEventsPayload> {
|
||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
||||
if (!Number.isSafeInteger(maxEvents) || maxEvents <= 0) {
|
||||
throw new RangeError("maxEvents must be a positive safe integer");
|
||||
}
|
||||
|
||||
const batchId = idField(body, ["batchId", "batch_id"], "invalid_batch_id");
|
||||
if (!batchId.ok) return batchId;
|
||||
const installId = installIdField(body);
|
||||
if (!installId.ok) return installId;
|
||||
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
|
||||
if (!appVersion.ok) return appVersion;
|
||||
const platform = stringField(body, ["platform"], 40, "invalid_platform");
|
||||
if (!platform.ok) return platform;
|
||||
|
||||
const batchSchema = schemaVersion(body);
|
||||
if (!batchSchema.ok) return batchSchema;
|
||||
const rawEvents = pick(body, ["events"]);
|
||||
if (!Array.isArray(rawEvents)) return failure(400, "invalid_events");
|
||||
if (rawEvents.length === 0) return failure(400, "empty_batch");
|
||||
if (rawEvents.length > maxEvents) return failure(400, "batch_too_large");
|
||||
|
||||
const events: NormalizedEvent[] = [];
|
||||
for (const rawEvent of rawEvents) {
|
||||
const event = normalizeEvent(rawEvent);
|
||||
if (!event.ok) return event;
|
||||
events.push(event.value);
|
||||
}
|
||||
|
||||
return success({
|
||||
batchId: batchId.value,
|
||||
installId: installId.value,
|
||||
appVersion: appVersion.value,
|
||||
platform: platform.value,
|
||||
events,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeCrash(body: JsonObject): InputResult<NormalizedCrashPayload> {
|
||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
||||
|
||||
const id = idField(body, ["id"], "invalid_id");
|
||||
if (!id.ok) return id;
|
||||
const installId = installIdField(body);
|
||||
if (!installId.ok) return installId;
|
||||
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
|
||||
if (!appVersion.ok) return appVersion;
|
||||
const platform = stringField(body, ["platform"], 40, "invalid_platform");
|
||||
if (!platform.ok) return platform;
|
||||
const exceptionType = stringField(
|
||||
body,
|
||||
["exceptionType", "exception_type"],
|
||||
120,
|
||||
"invalid_exception_type",
|
||||
true,
|
||||
true,
|
||||
);
|
||||
if (!exceptionType.ok) return exceptionType;
|
||||
const exceptionMessage = stringField(
|
||||
body,
|
||||
["exceptionMessage", "exception_message"],
|
||||
2_000,
|
||||
"invalid_exception_message",
|
||||
true,
|
||||
);
|
||||
if (!exceptionMessage.ok) return exceptionMessage;
|
||||
const stackTrace = stringField(
|
||||
body,
|
||||
["stackTrace", "stack_trace"],
|
||||
32_000,
|
||||
"invalid_stack_trace",
|
||||
true,
|
||||
);
|
||||
if (!stackTrace.ok) return stackTrace;
|
||||
const occurredAt = timestampField(
|
||||
body,
|
||||
["timestampMillis", "timestamp_millis", "occurredAt", "occurred_at"],
|
||||
);
|
||||
if (!occurredAt.ok) return occurredAt;
|
||||
const diagnosticsEnabled = booleanField(
|
||||
body,
|
||||
[
|
||||
"diagnosticsEnabledAtCapture",
|
||||
"diagnostics_enabled_at_capture",
|
||||
"diagnostics_enabled",
|
||||
],
|
||||
"invalid_diagnostics_enabled",
|
||||
true,
|
||||
);
|
||||
if (!diagnosticsEnabled.ok) return diagnosticsEnabled;
|
||||
const version = schemaVersion(body);
|
||||
if (!version.ok) return version;
|
||||
const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"]));
|
||||
if (!breadcrumbs.ok) return breadcrumbs;
|
||||
|
||||
return success({
|
||||
id: id.value,
|
||||
installId: installId.value,
|
||||
appVersion: appVersion.value,
|
||||
platform: platform.value,
|
||||
exceptionType: exceptionType.value,
|
||||
exceptionMessage: exceptionMessage.value,
|
||||
stackTrace: stackTrace.value,
|
||||
occurredAt: occurredAt.value,
|
||||
diagnosticsEnabledAtCapture: diagnosticsEnabled.value,
|
||||
breadcrumbs: breadcrumbs.value,
|
||||
schemaVersion: version.value,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload> {
|
||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
||||
|
||||
const id = idField(body, ["id"], "invalid_id");
|
||||
if (!id.ok) return id;
|
||||
const installId = installIdField(body);
|
||||
if (!installId.ok) return installId;
|
||||
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
|
||||
if (!appVersion.ok) return appVersion;
|
||||
const platform = stringField(body, ["platform"], 40, "invalid_platform");
|
||||
if (!platform.ok) return platform;
|
||||
const occurredAt = timestampField(
|
||||
body,
|
||||
["timestampMillis", "timestamp_millis", "occurredAt", "occurred_at"],
|
||||
);
|
||||
if (!occurredAt.ok) return occurredAt;
|
||||
const whatHappened = stringField(
|
||||
body,
|
||||
["whatHappened", "what_happened"],
|
||||
4_000,
|
||||
"missing_fields",
|
||||
true,
|
||||
true,
|
||||
);
|
||||
if (!whatHappened.ok) return whatHappened;
|
||||
const expected = stringField(body, ["expected"], 4_000, "missing_fields", true, true);
|
||||
if (!expected.ok) return expected;
|
||||
const steps = stringField(body, ["steps"], 4_000, "invalid_steps");
|
||||
if (!steps.ok) return steps;
|
||||
const contact = stringField(body, ["contact"], 320, "invalid_contact");
|
||||
if (!contact.ok) return contact;
|
||||
const includeLogs = booleanField(
|
||||
body,
|
||||
["includeLogs", "include_logs"],
|
||||
"invalid_include_logs",
|
||||
false,
|
||||
);
|
||||
if (!includeLogs.ok) return includeLogs;
|
||||
const logs = stringField(body, ["logs"], MAX_LOG_BYTES, "invalid_logs");
|
||||
if (!logs.ok) return logs;
|
||||
const device = normalizeDevice(pick(body, ["device"]));
|
||||
if (!device.ok) return device;
|
||||
const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"]));
|
||||
if (!breadcrumbs.ok) return breadcrumbs;
|
||||
const version = schemaVersion(body);
|
||||
if (!version.ok) return version;
|
||||
|
||||
return success({
|
||||
id: id.value,
|
||||
installId: installId.value,
|
||||
appVersion: appVersion.value,
|
||||
platform: platform.value,
|
||||
occurredAt: occurredAt.value,
|
||||
whatHappened: whatHappened.value,
|
||||
expected: expected.value,
|
||||
steps: steps.value,
|
||||
contact: contact.value,
|
||||
logs: includeLogs.value === true ? logs.value : "",
|
||||
device: device.value,
|
||||
breadcrumbs: breadcrumbs.value,
|
||||
schemaVersion: version.value,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeEvent(raw: unknown): InputResult<NormalizedEvent> {
|
||||
if (!isPlainObject(raw)) return failure(400, "invalid_event");
|
||||
const name = stringField(raw, ["name"], 64, "invalid_event", true, true);
|
||||
if (!name.ok) return name;
|
||||
const timestamp = timestampField(raw, ["timestampMillis", "timestamp_millis", "ts"]);
|
||||
if (!timestamp.ok) return failure(400, "invalid_event");
|
||||
const properties = normalizeProperties(pick(raw, ["properties", "props"]), "invalid_event");
|
||||
if (!properties.ok) return properties;
|
||||
const version = schemaVersion(raw);
|
||||
if (!version.ok) return version;
|
||||
return success({
|
||||
name: name.value,
|
||||
timestampMillis: timestamp.value,
|
||||
properties: properties.value,
|
||||
schemaVersion: version.value,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeBreadcrumbs(raw: unknown | typeof MISSING): InputResult<NormalizedBreadcrumb[]> {
|
||||
if (raw === MISSING) return success([]);
|
||||
if (!Array.isArray(raw)) return failure(400, "invalid_breadcrumbs");
|
||||
|
||||
const breadcrumbs: NormalizedBreadcrumb[] = [];
|
||||
for (const item of raw.slice(0, MAX_BREADCRUMBS)) {
|
||||
if (!isPlainObject(item)) return failure(400, "invalid_breadcrumbs");
|
||||
const name = stringField(item, ["name"], 64, "invalid_breadcrumbs", true, true);
|
||||
if (!name.ok) return name;
|
||||
const timestamp = timestampField(item, ["timestampMillis", "timestamp_millis", "ts"]);
|
||||
if (!timestamp.ok) return failure(400, "invalid_breadcrumbs");
|
||||
const properties = normalizeProperties(
|
||||
pick(item, ["properties", "props"]),
|
||||
"invalid_breadcrumbs",
|
||||
);
|
||||
if (!properties.ok) return properties;
|
||||
|
||||
breadcrumbs.push({
|
||||
name: name.value,
|
||||
timestampMillis: timestamp.value,
|
||||
properties: properties.value,
|
||||
});
|
||||
if (jsonBytes(breadcrumbs) > MAX_BREADCRUMBS_JSON_BYTES) {
|
||||
breadcrumbs.pop();
|
||||
break;
|
||||
}
|
||||
}
|
||||
return success(breadcrumbs);
|
||||
}
|
||||
|
||||
function normalizeProperties(
|
||||
raw: unknown | typeof MISSING,
|
||||
error: string,
|
||||
): InputResult<NormalizedProperties> {
|
||||
if (raw === MISSING) return success({});
|
||||
if (!isPlainObject(raw)) return failure(400, error);
|
||||
|
||||
const entries: Array<[string, string]> = [];
|
||||
const normalizedKeys = new Set<string>();
|
||||
for (const [key, value] of Object.entries(raw).slice(0, MAX_PROPERTIES)) {
|
||||
if (typeof value !== "string") return failure(400, error);
|
||||
const normalizedKey = truncateUtf8(key, 40);
|
||||
if (normalizedKey.length === 0 || normalizedKeys.has(normalizedKey)) {
|
||||
return failure(400, error);
|
||||
}
|
||||
normalizedKeys.add(normalizedKey);
|
||||
entries.push([normalizedKey, truncateUtf8(value, 128)]);
|
||||
}
|
||||
return success(Object.fromEntries(entries));
|
||||
}
|
||||
|
||||
function normalizeDevice(raw: unknown | typeof MISSING): InputResult<NormalizedDevice> {
|
||||
if (raw === MISSING) raw = {};
|
||||
if (!isPlainObject(raw)) return failure(400, "invalid_device");
|
||||
|
||||
const deviceName = stringField(raw, ["deviceName", "device_name"], 128, "invalid_device");
|
||||
if (!deviceName.ok) return deviceName;
|
||||
const deviceModel = stringField(raw, ["deviceModel", "device_model"], 128, "invalid_device");
|
||||
if (!deviceModel.ok) return deviceModel;
|
||||
const operatingSystem = stringField(
|
||||
raw,
|
||||
["operatingSystem", "operating_system"],
|
||||
192,
|
||||
"invalid_device",
|
||||
);
|
||||
if (!operatingSystem.ok) return operatingSystem;
|
||||
const network = stringField(raw, ["network"], 96, "invalid_device");
|
||||
if (!network.ok) return network;
|
||||
const batteryLevel = stringField(raw, ["batteryLevel", "battery_level"], 64, "invalid_device");
|
||||
if (!batteryLevel.ok) return batteryLevel;
|
||||
|
||||
const device: NormalizedDevice = {
|
||||
deviceName: deviceName.value,
|
||||
deviceModel: deviceModel.value,
|
||||
operatingSystem: operatingSystem.value,
|
||||
network: network.value,
|
||||
batteryLevel: batteryLevel.value,
|
||||
};
|
||||
if (jsonBytes(device) > MAX_DEVICE_JSON_BYTES) {
|
||||
return failure(400, "invalid_device");
|
||||
}
|
||||
return success(device);
|
||||
}
|
||||
|
||||
function stringField(
|
||||
object: JsonObject,
|
||||
keys: readonly string[],
|
||||
maxBytes: number,
|
||||
error: string,
|
||||
required = false,
|
||||
nonEmpty = false,
|
||||
): InputResult<string> {
|
||||
const raw = pick(object, keys);
|
||||
if (raw === MISSING) {
|
||||
return required ? failure(400, error) : success("");
|
||||
}
|
||||
if (typeof raw !== "string") return failure(400, error);
|
||||
const value = truncateUtf8(raw, maxBytes);
|
||||
if (nonEmpty && value.trim().length === 0) return failure(400, error);
|
||||
return success(value);
|
||||
}
|
||||
|
||||
function idField(
|
||||
object: JsonObject,
|
||||
keys: readonly string[],
|
||||
error: string,
|
||||
): InputResult<string> {
|
||||
const raw = pick(object, keys);
|
||||
if (typeof raw !== "string" || !UUID_PATTERN.test(raw)) return failure(400, error);
|
||||
return success(raw.toLowerCase());
|
||||
}
|
||||
|
||||
function installIdField(object: JsonObject): InputResult<string> {
|
||||
const raw = pick(object, ["installId", "install_id"]);
|
||||
if (raw === MISSING || raw === "") return success("unknown");
|
||||
if (
|
||||
typeof raw !== "string" ||
|
||||
raw.trim().length === 0 ||
|
||||
/[\u0000-\u001f\u007f]/.test(raw)
|
||||
) {
|
||||
return failure(400, "invalid_install_id");
|
||||
}
|
||||
return success(truncateUtf8(raw, 80));
|
||||
}
|
||||
|
||||
function timestampField(object: JsonObject, keys: readonly string[]): InputResult<number> {
|
||||
const raw = pick(object, keys);
|
||||
if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < 0) {
|
||||
return failure(400, "invalid_timestamp");
|
||||
}
|
||||
return success(raw);
|
||||
}
|
||||
|
||||
function booleanField(
|
||||
object: JsonObject,
|
||||
keys: readonly string[],
|
||||
error: string,
|
||||
required: true,
|
||||
): InputResult<boolean>;
|
||||
function booleanField(
|
||||
object: JsonObject,
|
||||
keys: readonly string[],
|
||||
error: string,
|
||||
required: false,
|
||||
): InputResult<boolean | undefined>;
|
||||
function booleanField(
|
||||
object: JsonObject,
|
||||
keys: readonly string[],
|
||||
error: string,
|
||||
required: boolean,
|
||||
): InputResult<boolean | undefined> {
|
||||
const raw = pick(object, keys);
|
||||
if (raw === MISSING) {
|
||||
return required ? failure(400, error) : success(undefined);
|
||||
}
|
||||
return typeof raw === "boolean" ? success(raw) : failure(400, error);
|
||||
}
|
||||
|
||||
function schemaVersion(object: JsonObject): InputResult<1> {
|
||||
const raw = pick(object, ["schemaVersion", "schema_version"]);
|
||||
if (raw === MISSING || raw === 1) return success(1);
|
||||
return failure(400, "unsupported_schema_version");
|
||||
}
|
||||
|
||||
function pick(object: JsonObject, keys: readonly string[]): unknown | typeof MISSING {
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(object, key)) return object[key];
|
||||
}
|
||||
return MISSING;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonObject {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function isApplicationJson(contentType: string | null): boolean {
|
||||
if (contentType == null) return false;
|
||||
const [mediaType, ...parameters] = contentType.split(";");
|
||||
if (mediaType?.trim().toLowerCase() !== "application/json") return false;
|
||||
for (const parameter of parameters) {
|
||||
const separator = parameter.indexOf("=");
|
||||
if (separator < 0) continue;
|
||||
if (parameter.slice(0, separator).trim().toLowerCase() !== "charset") continue;
|
||||
const charset = parameter
|
||||
.slice(separator + 1)
|
||||
.trim()
|
||||
.replace(/^"(.*)"$/, "$1")
|
||||
.toLowerCase();
|
||||
if (charset !== "utf-8" && charset !== "utf8") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function joinChunks(chunks: readonly Uint8Array[], totalBytes: number): Uint8Array {
|
||||
if (chunks.length === 1) return chunks[0] ?? new Uint8Array();
|
||||
const bytes = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function truncateUtf8(value: string, maxBytes: number): string {
|
||||
const bytes = UTF8_ENCODER.encode(value);
|
||||
if (bytes.byteLength <= maxBytes) return value;
|
||||
for (let end = maxBytes; end >= Math.max(0, maxBytes - 3); end -= 1) {
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||
bytes.subarray(0, end),
|
||||
);
|
||||
} catch {
|
||||
// A UTF-8 boundary is at most three bytes behind the byte cap.
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function jsonBytes(value: unknown): number {
|
||||
return UTF8_ENCODER.encode(JSON.stringify(value)).byteLength;
|
||||
}
|
||||
|
||||
function success<T>(value: T): InputResult<T> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
function failure(status: 400 | 413 | 415, error: string): InputFailure {
|
||||
return { ok: false, status, error };
|
||||
}
|
||||
340
services/diagnostics-api/src/storage.ts
Normal file
340
services/diagnostics-api/src/storage.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import type {
|
||||
NormalizedBugPayload,
|
||||
NormalizedCrashPayload,
|
||||
NormalizedEventsPayload,
|
||||
} from "./input";
|
||||
|
||||
export type DiagnosticsEnv = Cloudflare.Env & {
|
||||
INGEST_KEY?: string;
|
||||
AE?: AnalyticsEngineDataset;
|
||||
};
|
||||
|
||||
export interface StoreResult {
|
||||
id: string;
|
||||
duplicate: boolean;
|
||||
stored: number;
|
||||
}
|
||||
|
||||
export async function storeEvents(
|
||||
payload: NormalizedEventsPayload,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<StoreResult> {
|
||||
const result = await env.DB.prepare(
|
||||
`INSERT INTO event_batches (id, received_at, install_id, app_version, platform, event_count, payload_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING`,
|
||||
)
|
||||
.bind(
|
||||
payload.batchId,
|
||||
Date.now(),
|
||||
payload.installId,
|
||||
payload.appVersion,
|
||||
payload.platform,
|
||||
payload.events.length,
|
||||
JSON.stringify(payload.events),
|
||||
)
|
||||
.run();
|
||||
const duplicate = result.meta.changes === 0;
|
||||
if (!duplicate && env.AE) {
|
||||
try {
|
||||
for (const event of payload.events) {
|
||||
env.AE.writeDataPoint({
|
||||
blobs: [
|
||||
event.name,
|
||||
payload.platform,
|
||||
payload.appVersion,
|
||||
payload.installId,
|
||||
JSON.stringify(event.properties),
|
||||
payload.batchId,
|
||||
],
|
||||
doubles: [event.timestampMillis, event.schemaVersion],
|
||||
indexes: [payload.installId],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// D1 remains the durable source of truth if the optional analytics index is unavailable.
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: "failed to index diagnostics event batch",
|
||||
batchId: payload.batchId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: payload.batchId,
|
||||
duplicate,
|
||||
stored: duplicate ? 0 : payload.events.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function storeCrash(
|
||||
payload: NormalizedCrashPayload,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<StoreResult & { fingerprint: string }> {
|
||||
const database = env.DB.withSession("first-primary");
|
||||
const existing = await database
|
||||
.prepare("SELECT fingerprint FROM crashes WHERE id = ?")
|
||||
.bind(payload.id)
|
||||
.first<{ fingerprint: string }>();
|
||||
if (existing) {
|
||||
return { id: payload.id, duplicate: true, stored: 0, fingerprint: existing.fingerprint };
|
||||
}
|
||||
|
||||
const fingerprint = await crashFingerprint(payload.exceptionType, payload.stackTrace);
|
||||
const stackKey = payload.stackTrace
|
||||
? `crashes/${payload.id}/${crypto.randomUUID()}/stack.txt`
|
||||
: null;
|
||||
|
||||
if (stackKey) {
|
||||
await env.BLOBS.put(stackKey, payload.stackTrace, {
|
||||
httpMetadata: { contentType: "text/plain; charset=utf-8" },
|
||||
customMetadata: { installId: payload.installId, fingerprint },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await database
|
||||
.prepare(
|
||||
`INSERT INTO crashes (
|
||||
id, received_at, occurred_at, install_id, app_version, platform,
|
||||
exception_type, exception_message, fingerprint, diagnostics_enabled,
|
||||
stack_r2_key, breadcrumbs_json, schema_version
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING`,
|
||||
)
|
||||
.bind(
|
||||
payload.id,
|
||||
Date.now(),
|
||||
payload.occurredAt,
|
||||
payload.installId,
|
||||
payload.appVersion,
|
||||
payload.platform,
|
||||
payload.exceptionType,
|
||||
payload.exceptionMessage,
|
||||
fingerprint,
|
||||
payload.diagnosticsEnabledAtCapture ? 1 : 0,
|
||||
stackKey,
|
||||
JSON.stringify(payload.breadcrumbs),
|
||||
payload.schemaVersion,
|
||||
)
|
||||
.run();
|
||||
const duplicate = result.meta.changes === 0;
|
||||
if (duplicate) {
|
||||
const stored = await database
|
||||
.prepare("SELECT fingerprint FROM crashes WHERE id = ?")
|
||||
.bind(payload.id)
|
||||
.first<{ fingerprint: string }>();
|
||||
if (!stored) throw new Error("duplicate crash row was not readable");
|
||||
if (stackKey) await deleteAttemptBlob(env, stackKey);
|
||||
return { id: payload.id, duplicate: true, stored: 0, fingerprint: stored.fingerprint };
|
||||
}
|
||||
return { id: payload.id, duplicate: false, stored: 1, fingerprint };
|
||||
} catch (error) {
|
||||
if (stackKey) {
|
||||
await deleteAttemptBlob(env, stackKey);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function storeBug(
|
||||
payload: NormalizedBugPayload,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<StoreResult> {
|
||||
const database = env.DB.withSession("first-primary");
|
||||
const existing = await database
|
||||
.prepare("SELECT id FROM bugs WHERE id = ?")
|
||||
.bind(payload.id)
|
||||
.first<{ id: string }>();
|
||||
if (existing) return { id: payload.id, duplicate: true, stored: 0 };
|
||||
|
||||
const logsKey = payload.logs ? `bugs/${payload.id}/${crypto.randomUUID()}/logs.txt` : null;
|
||||
if (logsKey) {
|
||||
await env.BLOBS.put(logsKey, payload.logs, {
|
||||
httpMetadata: { contentType: "text/plain; charset=utf-8" },
|
||||
customMetadata: { installId: payload.installId },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await database
|
||||
.prepare(
|
||||
`INSERT INTO bugs (
|
||||
id, received_at, occurred_at, install_id, app_version, platform,
|
||||
what_happened, expected, steps, contact, logs_r2_key,
|
||||
device_json, breadcrumbs_json, status, schema_version
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?)
|
||||
ON CONFLICT(id) DO NOTHING`,
|
||||
)
|
||||
.bind(
|
||||
payload.id,
|
||||
Date.now(),
|
||||
payload.occurredAt,
|
||||
payload.installId,
|
||||
payload.appVersion,
|
||||
payload.platform,
|
||||
payload.whatHappened,
|
||||
payload.expected,
|
||||
payload.steps,
|
||||
payload.contact,
|
||||
logsKey,
|
||||
JSON.stringify(payload.device),
|
||||
JSON.stringify(payload.breadcrumbs),
|
||||
payload.schemaVersion,
|
||||
)
|
||||
.run();
|
||||
const duplicate = result.meta.changes === 0;
|
||||
if (duplicate && logsKey) {
|
||||
await deleteAttemptBlob(env, logsKey);
|
||||
}
|
||||
return { id: payload.id, duplicate, stored: duplicate ? 0 : 1 };
|
||||
} catch (error) {
|
||||
if (logsKey) {
|
||||
await deleteAttemptBlob(env, logsKey);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runRetention(env: DiagnosticsEnv): Promise<void> {
|
||||
const retentionDays = boundedPositiveInt(env.RETENTION_DAYS, 90, 1, 3_650);
|
||||
const cutoff = Date.now() - retentionDays * 86_400_000;
|
||||
// Eight full passes plus the backlog check use at most 43 of D1's 50 queries per invocation.
|
||||
for (let pass = 0; pass < 8; pass += 1) {
|
||||
const hasFullBatch = await runRetentionPass(env, cutoff);
|
||||
if (!hasFullBatch) return;
|
||||
}
|
||||
const [events, crashes, bugs] = await env.DB.batch<{ count: number }>([
|
||||
env.DB.prepare("SELECT COUNT(*) AS count FROM event_batches WHERE received_at < ?").bind(
|
||||
cutoff,
|
||||
),
|
||||
env.DB.prepare("SELECT COUNT(*) AS count FROM crashes WHERE received_at < ?").bind(cutoff),
|
||||
env.DB.prepare("SELECT COUNT(*) AS count FROM bugs WHERE received_at < ?").bind(cutoff),
|
||||
]);
|
||||
console.warn(
|
||||
JSON.stringify({
|
||||
message: "diagnostics retention reached its per-run pass limit",
|
||||
cutoff,
|
||||
backlog: {
|
||||
eventBatches: events.results[0]?.count ?? 0,
|
||||
crashes: crashes.results[0]?.count ?? 0,
|
||||
bugs: bugs.results[0]?.count ?? 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function runRetentionPass(env: DiagnosticsEnv, cutoff: number): Promise<boolean> {
|
||||
const reportBatchSize = 900;
|
||||
const eventBatchSize = 1_000;
|
||||
const [crashes, bugs] = await Promise.all([
|
||||
expiredBlobRows(env.DB, "crashes", "stack_r2_key", cutoff, reportBatchSize),
|
||||
expiredBlobRows(env.DB, "bugs", "logs_r2_key", cutoff, reportBatchSize),
|
||||
]);
|
||||
|
||||
const blobKeys = [...crashes, ...bugs]
|
||||
.map((row) => row.blobKey)
|
||||
.filter((key): key is string => key !== null);
|
||||
for (let offset = 0; offset < blobKeys.length; offset += 1_000) {
|
||||
await env.BLOBS.delete(blobKeys.slice(offset, offset + 1_000));
|
||||
}
|
||||
|
||||
const statements = [retentionStatement(env.DB, "event_batches", cutoff, eventBatchSize)];
|
||||
if (crashes.length > 0) statements.push(deleteRowsById(env.DB, "crashes", crashes));
|
||||
if (bugs.length > 0) statements.push(deleteRowsById(env.DB, "bugs", bugs));
|
||||
const [eventsResult] = await env.DB.batch(statements);
|
||||
return (
|
||||
eventsResult.meta.changes === eventBatchSize ||
|
||||
crashes.length === reportBatchSize ||
|
||||
bugs.length === reportBatchSize
|
||||
);
|
||||
}
|
||||
|
||||
interface ExpiredBlobRow {
|
||||
id: string;
|
||||
blobKey: string | null;
|
||||
}
|
||||
|
||||
async function expiredBlobRows(
|
||||
database: D1Database,
|
||||
table: "crashes" | "bugs",
|
||||
column: "stack_r2_key" | "logs_r2_key",
|
||||
cutoff: number,
|
||||
batchSize: number,
|
||||
): Promise<ExpiredBlobRow[]> {
|
||||
const result = await database
|
||||
.prepare(
|
||||
`SELECT id, ${column} AS blobKey
|
||||
FROM ${table}
|
||||
WHERE received_at < ?
|
||||
ORDER BY received_at
|
||||
LIMIT ?`,
|
||||
)
|
||||
.bind(cutoff, batchSize)
|
||||
.all<ExpiredBlobRow>();
|
||||
return result.results;
|
||||
}
|
||||
|
||||
function retentionStatement(
|
||||
database: D1Database,
|
||||
table: "event_batches" | "crashes" | "bugs",
|
||||
cutoff: number,
|
||||
batchSize: number,
|
||||
): D1PreparedStatement {
|
||||
return database
|
||||
.prepare(
|
||||
`DELETE FROM ${table}
|
||||
WHERE rowid IN (
|
||||
SELECT rowid FROM ${table} WHERE received_at < ? ORDER BY received_at LIMIT ?
|
||||
)`,
|
||||
)
|
||||
.bind(cutoff, batchSize);
|
||||
}
|
||||
|
||||
function deleteRowsById(
|
||||
database: D1Database,
|
||||
table: "crashes" | "bugs",
|
||||
rows: ExpiredBlobRow[],
|
||||
): D1PreparedStatement {
|
||||
return database
|
||||
.prepare(`DELETE FROM ${table} WHERE id IN (SELECT value FROM json_each(?))`)
|
||||
.bind(JSON.stringify(rows.map((row) => row.id)));
|
||||
}
|
||||
|
||||
async function crashFingerprint(exceptionType: string, stackTrace: string): Promise<string> {
|
||||
const topFrames = stackTrace
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.join("\n");
|
||||
const bytes = new TextEncoder().encode(`${exceptionType}\n${topFrames}`);
|
||||
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
||||
return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function deleteAttemptBlob(env: DiagnosticsEnv, key: string): Promise<void> {
|
||||
try {
|
||||
await env.BLOBS.delete(key);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: "failed to remove uncommitted diagnostics blob",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedPositiveInt(
|
||||
raw: string | undefined,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
4
services/diagnostics-api/test/apply-migrations.ts
Normal file
4
services/diagnostics-api/test/apply-migrations.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { applyD1Migrations } from "cloudflare:test";
|
||||
|
||||
await applyD1Migrations(env.DB, env.TEST_MIGRATIONS);
|
||||
12
services/diagnostics-api/test/env.d.ts
vendored
Normal file
12
services/diagnostics-api/test/env.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { D1Migration } from "@cloudflare/vitest-pool-workers";
|
||||
|
||||
declare global {
|
||||
namespace Cloudflare {
|
||||
interface Env {
|
||||
INGEST_KEY: string;
|
||||
TEST_MIGRATIONS: D1Migration[];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
266
services/diagnostics-api/test/input.test.ts
Normal file
266
services/diagnostics-api/test/input.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MAX_BREADCRUMBS_JSON_BYTES,
|
||||
MAX_DEVICE_JSON_BYTES,
|
||||
MAX_LOG_BYTES,
|
||||
normalizeBug,
|
||||
normalizeCrash,
|
||||
normalizeEvents,
|
||||
readJsonObject,
|
||||
} from "../src/input";
|
||||
|
||||
const ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const INSTALL_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
const ENCODER = new TextEncoder();
|
||||
|
||||
describe("readJsonObject", () => {
|
||||
it("enforces the byte limit on a chunked body without Content-Length", async () => {
|
||||
const bytes = ENCODER.encode(JSON.stringify({ value: "😀".repeat(40) }));
|
||||
const request = chunkedJsonRequest([
|
||||
bytes.subarray(0, 7),
|
||||
bytes.subarray(7, 31),
|
||||
bytes.subarray(31),
|
||||
]);
|
||||
|
||||
expect(request.headers.has("content-length")).toBe(false);
|
||||
await expect(readJsonObject(request, 64)).resolves.toEqual({
|
||||
ok: false,
|
||||
status: 413,
|
||||
error: "payload_too_large",
|
||||
});
|
||||
});
|
||||
|
||||
it("joins chunks before fatally decoding UTF-8", async () => {
|
||||
const bytes = ENCODER.encode(JSON.stringify({ value: "😀" }));
|
||||
const emojiStart = bytes.indexOf(0xf0);
|
||||
const request = chunkedJsonRequest([
|
||||
bytes.subarray(0, emojiStart + 2),
|
||||
bytes.subarray(emojiStart + 2),
|
||||
]);
|
||||
|
||||
await expect(readJsonObject(request, bytes.byteLength)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { value: "😀" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed UTF-8", async () => {
|
||||
const request = chunkedJsonRequest([
|
||||
new Uint8Array([0x7b, 0x22, 0x78, 0x22, 0x3a, 0x22, 0xc3, 0x28, 0x22, 0x7d]),
|
||||
]);
|
||||
|
||||
await expect(readJsonObject(request, 100)).resolves.toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: "invalid_utf8",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires application/json with a UTF-8 charset", async () => {
|
||||
const missing = new Request("https://example.test/v1/events", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
const wrongCharset = chunkedJsonRequest([ENCODER.encode("{}")], "application/json; charset=utf-16");
|
||||
|
||||
await expect(readJsonObject(missing, 100)).resolves.toEqual({
|
||||
ok: false,
|
||||
status: 415,
|
||||
error: "unsupported_media_type",
|
||||
});
|
||||
await expect(readJsonObject(wrongCharset, 100)).resolves.toEqual({
|
||||
ok: false,
|
||||
status: 415,
|
||||
error: "unsupported_media_type",
|
||||
});
|
||||
});
|
||||
|
||||
it("validates Content-Length before reading the body", async () => {
|
||||
const invalid = chunkedJsonRequest([ENCODER.encode("{}")], "application/json", "invalid");
|
||||
const oversized = chunkedJsonRequest(
|
||||
[ENCODER.encode("{}")],
|
||||
"application/json",
|
||||
"999999999999999999999999999999999999",
|
||||
);
|
||||
|
||||
await expect(readJsonObject(invalid, 100)).resolves.toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: "invalid_content_length",
|
||||
});
|
||||
await expect(readJsonObject(oversized, 100)).resolves.toEqual({
|
||||
ok: false,
|
||||
status: 413,
|
||||
error: "payload_too_large",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a non-object JSON root", async () => {
|
||||
const request = chunkedJsonRequest([ENCODER.encode("null")]);
|
||||
|
||||
await expect(readJsonObject(request, 100)).resolves.toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: "invalid_body",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizers", () => {
|
||||
it("preserves false booleans and rejects their string representation", () => {
|
||||
const crash = crashPayload(false);
|
||||
const normalizedCrash = normalizeCrash(crash);
|
||||
expect(normalizedCrash.ok).toBe(true);
|
||||
if (normalizedCrash.ok) {
|
||||
expect(normalizedCrash.value.diagnosticsEnabledAtCapture).toBe(false);
|
||||
}
|
||||
expect(normalizeCrash(crashPayload("false"))).toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: "invalid_diagnostics_enabled",
|
||||
});
|
||||
|
||||
const bug = bugPayload({ include_logs: false, logs: "discard me" });
|
||||
const normalizedBug = normalizeBug(bug);
|
||||
expect(normalizedBug.ok).toBe(true);
|
||||
if (normalizedBug.ok) expect(normalizedBug.value.logs).toBe("");
|
||||
const missingConsent = normalizeBug(bugPayload({ logs: "discard me" }));
|
||||
expect(missingConsent.ok && missingConsent.value.logs).toBe("");
|
||||
expect(normalizeBug(bugPayload({ include_logs: "false" }))).toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: "invalid_include_logs",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps logs, breadcrumbs, and device JSON within valid byte budgets", () => {
|
||||
const properties = Object.fromEntries(
|
||||
Array.from({ length: 12 }, (_, index) => [`key-${index}-${"\u0000".repeat(40)}`, "\u0000".repeat(128)]),
|
||||
);
|
||||
const breadcrumbs = Array.from({ length: 40 }, (_, index) => ({
|
||||
name: `crumb-${index}`,
|
||||
timestamp_millis: index,
|
||||
properties,
|
||||
}));
|
||||
const result = normalizeBug(
|
||||
bugPayload({
|
||||
include_logs: true,
|
||||
logs: "😀".repeat(60_000),
|
||||
breadcrumbs,
|
||||
device: {
|
||||
device_name: "\u0000".repeat(200),
|
||||
device_model: "\u0000".repeat(200),
|
||||
operating_system: "\u0000".repeat(300),
|
||||
network: "\u0000".repeat(150),
|
||||
battery_level: "\u0000".repeat(100),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
const breadcrumbsJson = JSON.stringify(result.value.breadcrumbs);
|
||||
const deviceJson = JSON.stringify(result.value.device);
|
||||
expect(ENCODER.encode(result.value.logs).byteLength).toBe(MAX_LOG_BYTES);
|
||||
expect(ENCODER.encode(breadcrumbsJson).byteLength).toBeLessThanOrEqual(
|
||||
MAX_BREADCRUMBS_JSON_BYTES,
|
||||
);
|
||||
expect(ENCODER.encode(deviceJson).byteLength).toBeLessThanOrEqual(MAX_DEVICE_JSON_BYTES);
|
||||
expect(JSON.parse(breadcrumbsJson)).toEqual(result.value.breadcrumbs);
|
||||
expect(JSON.parse(deviceJson)).toEqual(result.value.device);
|
||||
});
|
||||
|
||||
it("requires stable report IDs and validates supplied IDs and schema versions", () => {
|
||||
const result = normalizeEvents({
|
||||
events: [{ name: "opened", ts: 1, schema_version: 1 }],
|
||||
});
|
||||
expect(result).toEqual({ ok: false, status: 400, error: "invalid_batch_id" });
|
||||
const legacyInstall = normalizeEvents({
|
||||
batch_id: ID,
|
||||
install_id: "legacy-test-install",
|
||||
events: [{ name: "opened", ts: 1 }],
|
||||
});
|
||||
expect(legacyInstall.ok && legacyInstall.value.installId).toBe("legacy-test-install");
|
||||
const missingInstall = normalizeEvents({
|
||||
batch_id: ID,
|
||||
events: [{ name: "opened", ts: 1 }],
|
||||
});
|
||||
expect(missingInstall.ok && missingInstall.value.installId).toBe("unknown");
|
||||
expect(
|
||||
normalizeEvents({
|
||||
batch_id: ID,
|
||||
install_id: "bad\u0000install",
|
||||
events: [{ name: "opened", ts: 1 }],
|
||||
}),
|
||||
).toEqual({ ok: false, status: 400, error: "invalid_install_id" });
|
||||
|
||||
expect(
|
||||
normalizeEvents({
|
||||
batch_id: "not-a-uuid",
|
||||
events: [{ name: "opened", timestamp_millis: 1 }],
|
||||
}),
|
||||
).toEqual({ ok: false, status: 400, error: "invalid_batch_id" });
|
||||
expect(
|
||||
normalizeEvents({
|
||||
batch_id: ID,
|
||||
install_id: INSTALL_ID,
|
||||
events: [{ name: "opened", timestamp_millis: 1, schema_version: 2 }],
|
||||
}),
|
||||
).toEqual({ ok: false, status: 400, error: "unsupported_schema_version" });
|
||||
});
|
||||
});
|
||||
|
||||
function chunkedJsonRequest(
|
||||
chunks: readonly Uint8Array[],
|
||||
contentType = "application/json; charset=utf-8",
|
||||
contentLength?: string,
|
||||
): Request {
|
||||
return new Request("https://example.test/v1/events", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": contentType,
|
||||
...(contentLength == null ? {} : { "content-length": contentLength }),
|
||||
},
|
||||
body: new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(chunk);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function crashPayload(diagnosticsEnabled: unknown): Record<string, unknown> {
|
||||
return {
|
||||
id: ID,
|
||||
install_id: INSTALL_ID,
|
||||
app_version: "1.0",
|
||||
platform: "test",
|
||||
exception_type: "ExampleError",
|
||||
exception_message: "message",
|
||||
stack_trace: "stack",
|
||||
occurred_at: 1,
|
||||
diagnostics_enabled: diagnosticsEnabled,
|
||||
schema_version: 1,
|
||||
breadcrumbs: [],
|
||||
};
|
||||
}
|
||||
|
||||
function bugPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: ID,
|
||||
install_id: INSTALL_ID,
|
||||
app_version: "1.0",
|
||||
platform: "test",
|
||||
occurred_at: 1,
|
||||
what_happened: "It failed",
|
||||
expected: "It worked",
|
||||
steps: "Open the app",
|
||||
contact: "",
|
||||
logs: "",
|
||||
device: {},
|
||||
breadcrumbs: [],
|
||||
schema_version: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
7
services/diagnostics-api/test/tsconfig.json
Normal file
7
services/diagnostics-api/test/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["@cloudflare/vitest-pool-workers/types", "node"]
|
||||
},
|
||||
"include": ["../worker-configuration.d.ts", "../vitest.config.ts", "./**/*.ts"]
|
||||
}
|
||||
606
services/diagnostics-api/test/worker.test.ts
Normal file
606
services/diagnostics-api/test/worker.test.ts
Normal file
@@ -0,0 +1,606 @@
|
||||
import { env, exports } from "cloudflare:workers";
|
||||
import { createExecutionContext } from "cloudflare:test";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import worker from "../src/index";
|
||||
import type {
|
||||
NormalizedBugPayload,
|
||||
NormalizedCrashPayload,
|
||||
NormalizedEventsPayload,
|
||||
} from "../src/input";
|
||||
import {
|
||||
type DiagnosticsEnv,
|
||||
runRetention,
|
||||
storeBug,
|
||||
storeCrash,
|
||||
storeEvents,
|
||||
} from "../src/storage";
|
||||
|
||||
const INSTALL_ID = "10000000-0000-4000-8000-000000000000";
|
||||
|
||||
describe("diagnostics Worker", () => {
|
||||
it("keeps public routing narrow and fails closed", async () => {
|
||||
const live = await exports.default.fetch(new Request("https://diagnostics.test/live"));
|
||||
expect(live.status).toBe(200);
|
||||
expect(await live.json()).toMatchObject({ ok: true, schema: 1 });
|
||||
|
||||
const health = await exports.default.fetch(healthRequest());
|
||||
expect(health.status).toBe(200);
|
||||
expect(await health.json()).toMatchObject({ ok: true });
|
||||
const unauthorizedHealth = await exports.default.fetch(healthRequest("wrong-key"));
|
||||
expect(unauthorizedHealth.status).toBe(401);
|
||||
|
||||
const unknown = await exports.default.fetch(
|
||||
new Request("https://diagnostics.test/v1/not-real", { method: "POST" }),
|
||||
);
|
||||
expect(unknown.status).toBe(404);
|
||||
|
||||
const unauthorized = await exports.default.fetch(
|
||||
jsonRequest("/v1/events", eventPayload(uuid(1)), "wrong-key"),
|
||||
);
|
||||
expect(unauthorized.status).toBe(401);
|
||||
expect(await unauthorized.json()).toEqual({ error: "unauthorized" });
|
||||
expect(unauthorized.headers.get("x-request-id")).toMatch(
|
||||
/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
|
||||
const preflight = await exports.default.fetch(
|
||||
new Request("https://diagnostics.test/v1/events", { method: "OPTIONS" }),
|
||||
);
|
||||
expect(preflight.status).toBe(204);
|
||||
expect(preflight.headers.get("access-control-allow-origin")).toBeNull();
|
||||
expect(preflight.headers.get("allow")).toBe("POST, OPTIONS");
|
||||
});
|
||||
|
||||
it("applies the source limit before shared-key verification", async () => {
|
||||
let installLimitCalls = 0;
|
||||
const limitedEnv: DiagnosticsEnv = {
|
||||
...env,
|
||||
SOURCE_RATE_LIMITER: {
|
||||
limit: async () => ({ success: false }),
|
||||
} as RateLimit,
|
||||
INSTALL_RATE_LIMITER: {
|
||||
limit: async () => {
|
||||
installLimitCalls += 1;
|
||||
return { success: true };
|
||||
},
|
||||
} as RateLimit,
|
||||
};
|
||||
const context = createExecutionContext();
|
||||
|
||||
const response = await worker.fetch(
|
||||
jsonRequest("/v1/events", eventPayload(uuid(3)), "wrong-key", "198.51.100.3"),
|
||||
limitedEnv,
|
||||
context,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.headers.get("retry-after")).toBe("60");
|
||||
expect(await response.json()).toEqual({ error: "rate_limited" });
|
||||
expect(installLimitCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("returns structured errors for invalid bodies and asynchronous storage failures", async () => {
|
||||
const invalid = await exports.default.fetch(jsonRequest("/v1/events", null));
|
||||
expect(invalid.status).toBe(400);
|
||||
expect(await invalid.json()).toEqual({ error: "invalid_body" });
|
||||
|
||||
const rejection = new Error("simulated D1 rejection");
|
||||
const statement = {
|
||||
bind: () => statement,
|
||||
first: async () => Promise.reject(rejection),
|
||||
run: async () => Promise.reject(rejection),
|
||||
};
|
||||
const rejectingDatabase = {
|
||||
prepare: () => statement,
|
||||
} as unknown as D1Database;
|
||||
const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase };
|
||||
|
||||
const healthContext = createExecutionContext();
|
||||
const unhealthy = await worker.fetch(
|
||||
healthRequest(env.INGEST_KEY, "198.51.100.4"),
|
||||
rejectingEnv,
|
||||
healthContext,
|
||||
);
|
||||
expect(unhealthy.status).toBe(503);
|
||||
expect(await unhealthy.json()).toEqual({ ok: false, error: "dependency_unavailable" });
|
||||
|
||||
const ingestContext = createExecutionContext();
|
||||
const failedIngest = await worker.fetch(
|
||||
jsonRequest("/v1/events", eventPayload(uuid(2)), env.INGEST_KEY, "198.51.100.2"),
|
||||
rejectingEnv,
|
||||
ingestContext,
|
||||
);
|
||||
expect(failedIngest.status).toBe(500);
|
||||
expect(await failedIngest.json()).toEqual({ error: "internal" });
|
||||
});
|
||||
|
||||
it("deduplicates event batches using the client batch ID", async () => {
|
||||
const id = uuid(10);
|
||||
const first = await exports.default.fetch(jsonRequest("/v1/events", eventPayload(id)));
|
||||
const second = await exports.default.fetch(jsonRequest("/v1/events", eventPayload(id)));
|
||||
|
||||
expect(first.status).toBe(202);
|
||||
expect(await first.json()).toMatchObject({
|
||||
ok: true,
|
||||
id,
|
||||
stored: 1,
|
||||
duplicate: false,
|
||||
});
|
||||
expect(second.status).toBe(202);
|
||||
expect(await second.json()).toMatchObject({
|
||||
ok: true,
|
||||
id,
|
||||
stored: 0,
|
||||
duplicate: true,
|
||||
});
|
||||
|
||||
const row = await env.DB.prepare(
|
||||
"SELECT event_count AS eventCount, payload_json AS payloadJson FROM event_batches WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.first<{ eventCount: number; payloadJson: string }>();
|
||||
expect(row?.eventCount).toBe(1);
|
||||
expect(JSON.parse(row?.payloadJson ?? "null")).toEqual([
|
||||
{
|
||||
name: "app_open",
|
||||
timestampMillis: 1,
|
||||
properties: { screen: "home" },
|
||||
schemaVersion: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps D1 idempotency when the optional analytics index is enabled", async () => {
|
||||
const points: AnalyticsEngineDataPoint[] = [];
|
||||
const analytics = {
|
||||
writeDataPoint: (point: AnalyticsEngineDataPoint) => points.push(point),
|
||||
} as AnalyticsEngineDataset;
|
||||
const analyticsEnv: DiagnosticsEnv = { ...env, AE: analytics };
|
||||
const payload: NormalizedEventsPayload = {
|
||||
batchId: uuid(11),
|
||||
installId: INSTALL_ID,
|
||||
appVersion: "1.0",
|
||||
platform: "test",
|
||||
events: [
|
||||
{
|
||||
name: "indexed",
|
||||
timestampMillis: 1,
|
||||
properties: {},
|
||||
schemaVersion: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(await storeEvents(payload, analyticsEnv)).toMatchObject({ duplicate: false, stored: 1 });
|
||||
expect(await storeEvents(payload, analyticsEnv)).toMatchObject({ duplicate: true, stored: 0 });
|
||||
expect(points).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the accepted crash blob when a duplicate request arrives", async () => {
|
||||
const id = uuid(20);
|
||||
const first = await exports.default.fetch(
|
||||
jsonRequest("/v1/crashes", crashPayload(id, "first stack")),
|
||||
);
|
||||
const second = await exports.default.fetch(
|
||||
jsonRequest("/v1/crashes", crashPayload(id, "second stack")),
|
||||
);
|
||||
|
||||
expect(first.status).toBe(202);
|
||||
const firstBody = await first.json<{ fingerprint: string }>();
|
||||
expect(firstBody).toMatchObject({ ok: true, id, duplicate: false });
|
||||
expect(second.status).toBe(202);
|
||||
const secondBody = await second.json<{ fingerprint: string }>();
|
||||
expect(secondBody).toMatchObject({ ok: true, id, duplicate: true });
|
||||
|
||||
const row = await env.DB.prepare(
|
||||
`SELECT stack_r2_key AS stackKey, breadcrumbs_json AS breadcrumbsJson,
|
||||
fingerprint
|
||||
FROM crashes WHERE id = ?`,
|
||||
)
|
||||
.bind(id)
|
||||
.first<{ stackKey: string; breadcrumbsJson: string; fingerprint: string }>();
|
||||
expect(row?.stackKey).toMatch(new RegExp(`^crashes/${id}/[0-9a-f-]+/stack\\.txt$`));
|
||||
expect(firstBody.fingerprint).toBe(row?.fingerprint);
|
||||
expect(secondBody.fingerprint).toBe(row?.fingerprint);
|
||||
expect(JSON.parse(row?.breadcrumbsJson ?? "null")).toEqual([]);
|
||||
expect(await (await env.BLOBS.get(row?.stackKey ?? "missing"))?.text()).toBe("first stack");
|
||||
|
||||
const objects = await env.BLOBS.list({ prefix: `crashes/${id}/` });
|
||||
expect(objects.objects.map((object) => object.key)).toEqual([row?.stackKey]);
|
||||
});
|
||||
|
||||
it("stores bug metadata as JSON and cleans the duplicate upload attempt", async () => {
|
||||
const id = uuid(30);
|
||||
const payload = bugPayload(id, "first logs");
|
||||
const first = await exports.default.fetch(jsonRequest("/v1/bugs", payload));
|
||||
const second = await exports.default.fetch(
|
||||
jsonRequest("/v1/bugs", bugPayload(id, "second logs")),
|
||||
);
|
||||
|
||||
expect(first.status).toBe(202);
|
||||
expect(await first.json()).toMatchObject({ ok: true, id, duplicate: false });
|
||||
expect(second.status).toBe(202);
|
||||
expect(await second.json()).toMatchObject({ ok: true, id, duplicate: true });
|
||||
|
||||
const row = await env.DB.prepare(
|
||||
`SELECT occurred_at AS occurredAt, logs_r2_key AS logsKey,
|
||||
device_json AS deviceJson, breadcrumbs_json AS breadcrumbsJson
|
||||
FROM bugs WHERE id = ?`,
|
||||
)
|
||||
.bind(id)
|
||||
.first<{
|
||||
occurredAt: number;
|
||||
logsKey: string;
|
||||
deviceJson: string;
|
||||
breadcrumbsJson: string;
|
||||
}>();
|
||||
expect(row?.occurredAt).toBe(3);
|
||||
expect(JSON.parse(row?.deviceJson ?? "null")).toEqual({
|
||||
deviceName: "Test device",
|
||||
deviceModel: "Model",
|
||||
operatingSystem: "Test OS",
|
||||
network: "offline",
|
||||
batteryLevel: "90%",
|
||||
});
|
||||
expect(JSON.parse(row?.breadcrumbsJson ?? "null")).toEqual([
|
||||
{ name: "opened", timestampMillis: 2, properties: {} },
|
||||
]);
|
||||
expect(await (await env.BLOBS.get(row?.logsKey ?? "missing"))?.text()).toBe("first logs");
|
||||
|
||||
const objects = await env.BLOBS.list({ prefix: `bugs/${id}/` });
|
||||
expect(objects.objects.map((object) => object.key)).toEqual([row?.logsKey]);
|
||||
});
|
||||
|
||||
it("acknowledges known report IDs without touching an unavailable blob store", async () => {
|
||||
const crash = normalizedCrash(uuid(31), "accepted stack");
|
||||
const bug = normalizedBug(uuid(32), "accepted logs");
|
||||
const firstCrash = await storeCrash(crash, env);
|
||||
await storeBug(bug, env);
|
||||
let blobWrites = 0;
|
||||
const unavailableBlobs = {
|
||||
put: async () => {
|
||||
blobWrites += 1;
|
||||
throw new Error("simulated R2 outage");
|
||||
},
|
||||
} as unknown as R2Bucket;
|
||||
const unavailableEnv: DiagnosticsEnv = { ...env, BLOBS: unavailableBlobs };
|
||||
|
||||
await expect(
|
||||
storeCrash({ ...crash, stackTrace: "retry stack" }, unavailableEnv),
|
||||
).resolves.toEqual({
|
||||
id: crash.id,
|
||||
duplicate: true,
|
||||
stored: 0,
|
||||
fingerprint: firstCrash.fingerprint,
|
||||
});
|
||||
await expect(
|
||||
storeBug({ ...bug, logs: "retry logs" }, unavailableEnv),
|
||||
).resolves.toEqual({ id: bug.id, duplicate: true, stored: 0 });
|
||||
expect(blobWrites).toBe(0);
|
||||
});
|
||||
|
||||
it("removes uploaded report blobs when D1 rejects the metadata write", async () => {
|
||||
const rejection = new Error("simulated D1 write rejection");
|
||||
const rejectingDatabase = databaseWithSession(
|
||||
() => null,
|
||||
async () => Promise.reject(rejection),
|
||||
);
|
||||
const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase };
|
||||
const crashId = uuid(33);
|
||||
const bugId = uuid(34);
|
||||
|
||||
const crashResponse = await worker.fetch(
|
||||
jsonRequest("/v1/crashes", crashPayload(crashId, "orphan candidate"), env.INGEST_KEY, "198.51.100.33"),
|
||||
rejectingEnv,
|
||||
createExecutionContext(),
|
||||
);
|
||||
const bugResponse = await worker.fetch(
|
||||
jsonRequest("/v1/bugs", bugPayload(bugId, "orphan candidate"), env.INGEST_KEY, "198.51.100.34"),
|
||||
rejectingEnv,
|
||||
createExecutionContext(),
|
||||
);
|
||||
|
||||
expect(crashResponse.status).toBe(500);
|
||||
expect(await crashResponse.json()).toEqual({ error: "internal" });
|
||||
expect(bugResponse.status).toBe(500);
|
||||
expect(await bugResponse.json()).toEqual({ error: "internal" });
|
||||
expect((await env.BLOBS.list({ prefix: `crashes/${crashId}/` })).objects).toEqual([]);
|
||||
expect((await env.BLOBS.list({ prefix: `bugs/${bugId}/` })).objects).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes expired rows and their exact R2 objects while preserving current data", async () => {
|
||||
const oldEventId = uuid(40);
|
||||
const oldCrashId = uuid(41);
|
||||
const oldBugId = uuid(42);
|
||||
const currentEventId = uuid(43);
|
||||
const oldCrashKey = `crashes/${oldCrashId}/retention/stack.txt`;
|
||||
const oldBugKey = `bugs/${oldBugId}/retention/logs.txt`;
|
||||
const oldReceivedAt = Date.now() - 100 * 86_400_000;
|
||||
|
||||
await Promise.all([
|
||||
env.BLOBS.put(oldCrashKey, "expired crash"),
|
||||
env.BLOBS.put(oldBugKey, "expired logs"),
|
||||
]);
|
||||
await env.DB.batch([
|
||||
env.DB.prepare(
|
||||
`INSERT INTO event_batches
|
||||
(id, received_at, install_id, app_version, platform, event_count, payload_json)
|
||||
VALUES (?, ?, ?, '', '', 1, '[]')`,
|
||||
).bind(oldEventId, oldReceivedAt, INSTALL_ID),
|
||||
env.DB.prepare(
|
||||
`INSERT INTO event_batches
|
||||
(id, received_at, install_id, app_version, platform, event_count, payload_json)
|
||||
VALUES (?, ?, ?, '', '', 1, '[]')`,
|
||||
).bind(currentEventId, Date.now(), INSTALL_ID),
|
||||
env.DB.prepare(
|
||||
`INSERT INTO crashes
|
||||
(id, received_at, occurred_at, install_id, app_version, platform,
|
||||
exception_type, exception_message, fingerprint, diagnostics_enabled,
|
||||
stack_r2_key, breadcrumbs_json, schema_version)
|
||||
VALUES (?, ?, ?, ?, '', '', 'Error', '', 'fingerprint', 1, ?, '[]', 1)`,
|
||||
).bind(oldCrashId, oldReceivedAt, oldReceivedAt, INSTALL_ID, oldCrashKey),
|
||||
env.DB.prepare(
|
||||
`INSERT INTO bugs
|
||||
(id, received_at, occurred_at, install_id, app_version, platform,
|
||||
what_happened, expected, steps, contact, logs_r2_key,
|
||||
device_json, breadcrumbs_json, status, schema_version)
|
||||
VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', ?, '{}', '[]', 'open', 1)`,
|
||||
).bind(oldBugId, oldReceivedAt, oldReceivedAt, INSTALL_ID, oldBugKey),
|
||||
]);
|
||||
|
||||
await runRetention(env);
|
||||
|
||||
for (const [table, id] of [
|
||||
["event_batches", oldEventId],
|
||||
["crashes", oldCrashId],
|
||||
["bugs", oldBugId],
|
||||
] as const) {
|
||||
const row = await env.DB.prepare(`SELECT id FROM ${table} WHERE id = ?`).bind(id).first();
|
||||
expect(row).toBeNull();
|
||||
}
|
||||
expect(await env.BLOBS.head(oldCrashKey)).toBeNull();
|
||||
expect(await env.BLOBS.head(oldBugKey)).toBeNull();
|
||||
expect(
|
||||
await env.DB.prepare("SELECT id FROM event_batches WHERE id = ?").bind(currentEventId).first(),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("bounds a full retention run below the D1 per-invocation query limit", async () => {
|
||||
let queryCount = 0;
|
||||
let batchCalls = 0;
|
||||
const blobDeleteBatchSizes: number[] = [];
|
||||
const rows = Array.from({ length: 900 }, (_, index) => ({
|
||||
id: `expired-${index}`,
|
||||
blobKey: `expired/${index}`,
|
||||
}));
|
||||
const database = {
|
||||
prepare: () => {
|
||||
const statement = {
|
||||
bind: () => statement,
|
||||
all: async () => {
|
||||
queryCount += 1;
|
||||
return d1Result(rows, 0);
|
||||
},
|
||||
};
|
||||
return statement;
|
||||
},
|
||||
batch: async (statements: D1PreparedStatement[]) => {
|
||||
batchCalls += 1;
|
||||
queryCount += statements.length;
|
||||
if (batchCalls === 9) {
|
||||
return statements.map(() => d1Result([{ count: 1 }], 0));
|
||||
}
|
||||
return statements.map((_, index) => d1Result([], index === 0 ? 1_000 : 900));
|
||||
},
|
||||
} as unknown as D1Database;
|
||||
const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const blobs = {
|
||||
delete: async (keys: string | string[]) => {
|
||||
blobDeleteBatchSizes.push(typeof keys === "string" ? 1 : keys.length);
|
||||
},
|
||||
} as unknown as R2Bucket;
|
||||
|
||||
try {
|
||||
await runRetention({ ...env, DB: database, BLOBS: blobs });
|
||||
} finally {
|
||||
warning.mockRestore();
|
||||
}
|
||||
|
||||
expect(queryCount).toBe(43);
|
||||
expect(blobDeleteBatchSizes).toHaveLength(16);
|
||||
expect(Math.max(...blobDeleteBatchSizes)).toBe(1_000);
|
||||
});
|
||||
|
||||
it("converges an expired report backlog across bounded retention runs", async () => {
|
||||
const oldReceivedAt = Date.now() - 100 * 86_400_000;
|
||||
await env.DB.prepare(
|
||||
`WITH digits(value) AS (
|
||||
VALUES (0), (1), (2), (3), (4), (5), (6), (7), (8), (9)
|
||||
), sequence(value) AS (
|
||||
SELECT thousands.value * 1000 + hundreds.value * 100 + tens.value * 10 + ones.value + 1
|
||||
FROM digits AS thousands
|
||||
CROSS JOIN digits AS hundreds
|
||||
CROSS JOIN digits AS tens
|
||||
CROSS JOIN digits AS ones
|
||||
WHERE thousands.value * 1000 + hundreds.value * 100 + tens.value * 10 + ones.value < 7201
|
||||
)
|
||||
INSERT INTO crashes (
|
||||
id, received_at, occurred_at, install_id, app_version, platform,
|
||||
exception_type, exception_message, fingerprint, diagnostics_enabled,
|
||||
stack_r2_key, breadcrumbs_json, schema_version
|
||||
)
|
||||
SELECT 'retention-backlog-' || printf('%04d', value), ?, ?, ?, '', '',
|
||||
'Error', '', 'fingerprint-' || value, 0, NULL, '[]', 1
|
||||
FROM sequence`,
|
||||
)
|
||||
.bind(oldReceivedAt, oldReceivedAt, INSTALL_ID)
|
||||
.run();
|
||||
const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await runRetention(env);
|
||||
const afterFirstRun = await env.DB.prepare(
|
||||
"SELECT COUNT(*) AS count FROM crashes WHERE id LIKE 'retention-backlog-%'",
|
||||
).first<{ count: number }>();
|
||||
expect(afterFirstRun?.count).toBe(1);
|
||||
|
||||
await runRetention(env);
|
||||
const afterSecondRun = await env.DB.prepare(
|
||||
"SELECT COUNT(*) AS count FROM crashes WHERE id LIKE 'retention-backlog-%'",
|
||||
).first<{ count: number }>();
|
||||
expect(afterSecondRun?.count).toBe(0);
|
||||
} finally {
|
||||
warning.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function jsonRequest(
|
||||
path: string,
|
||||
body: unknown,
|
||||
key = env.INGEST_KEY,
|
||||
source = "198.51.100.1",
|
||||
): Request {
|
||||
return new Request(`https://diagnostics.test${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cf-connecting-ip": source,
|
||||
"x-vnidrop-install-id": INSTALL_ID,
|
||||
"x-vnidrop-key": key,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function healthRequest(key = env.INGEST_KEY, source = "198.51.100.1"): Request {
|
||||
return new Request("https://diagnostics.test/health", {
|
||||
headers: {
|
||||
"cf-connecting-ip": source,
|
||||
"x-vnidrop-key": key,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function eventPayload(batchId: string): Record<string, unknown> {
|
||||
return {
|
||||
batchId,
|
||||
installId: INSTALL_ID,
|
||||
appVersion: "1.0",
|
||||
platform: "test",
|
||||
events: [
|
||||
{
|
||||
name: "app_open",
|
||||
timestampMillis: 1,
|
||||
properties: { screen: "home" },
|
||||
schemaVersion: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function crashPayload(id: string, stackTrace: string): Record<string, unknown> {
|
||||
return {
|
||||
id,
|
||||
installId: INSTALL_ID,
|
||||
appVersion: "1.0",
|
||||
platform: "test",
|
||||
exceptionType: "TestError",
|
||||
exceptionMessage: "failed",
|
||||
stackTrace,
|
||||
timestampMillis: 2,
|
||||
diagnosticsEnabledAtCapture: true,
|
||||
breadcrumbs: [],
|
||||
schemaVersion: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function bugPayload(id: string, logs: string): Record<string, unknown> {
|
||||
return {
|
||||
id,
|
||||
installId: INSTALL_ID,
|
||||
appVersion: "1.0",
|
||||
platform: "test",
|
||||
timestampMillis: 3,
|
||||
whatHappened: "It failed",
|
||||
expected: "It should work",
|
||||
steps: "Open the app",
|
||||
contact: "",
|
||||
includeLogs: true,
|
||||
logs,
|
||||
device: {
|
||||
deviceName: "Test device",
|
||||
deviceModel: "Model",
|
||||
operatingSystem: "Test OS",
|
||||
network: "offline",
|
||||
batteryLevel: "90%",
|
||||
},
|
||||
breadcrumbs: [{ name: "opened", timestampMillis: 2, properties: {} }],
|
||||
schemaVersion: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedCrash(id: string, stackTrace: string): NormalizedCrashPayload {
|
||||
return {
|
||||
id,
|
||||
installId: INSTALL_ID,
|
||||
appVersion: "1.0",
|
||||
platform: "test",
|
||||
exceptionType: "TestError",
|
||||
exceptionMessage: "failed",
|
||||
stackTrace,
|
||||
occurredAt: 2,
|
||||
diagnosticsEnabledAtCapture: true,
|
||||
breadcrumbs: [],
|
||||
schemaVersion: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedBug(id: string, logs: string): NormalizedBugPayload {
|
||||
return {
|
||||
id,
|
||||
installId: INSTALL_ID,
|
||||
appVersion: "1.0",
|
||||
platform: "test",
|
||||
occurredAt: 3,
|
||||
whatHappened: "It failed",
|
||||
expected: "It should work",
|
||||
steps: "Open the app",
|
||||
contact: "",
|
||||
logs,
|
||||
device: {
|
||||
deviceName: "Test device",
|
||||
deviceModel: "Model",
|
||||
operatingSystem: "Test OS",
|
||||
network: "offline",
|
||||
batteryLevel: "90%",
|
||||
},
|
||||
breadcrumbs: [],
|
||||
schemaVersion: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function databaseWithSession(
|
||||
first: () => unknown,
|
||||
run: () => Promise<D1Result>,
|
||||
): D1Database {
|
||||
const statement = {
|
||||
bind: () => statement,
|
||||
first: async () => first(),
|
||||
run,
|
||||
} as unknown as D1PreparedStatement;
|
||||
const session = {
|
||||
prepare: () => statement,
|
||||
} as unknown as D1DatabaseSession;
|
||||
return {
|
||||
withSession: () => session,
|
||||
} as unknown as D1Database;
|
||||
}
|
||||
|
||||
function d1Result<T>(results: T[], changes: number): D1Result<T> {
|
||||
return { success: true, results, meta: { changes } } as D1Result<T>;
|
||||
}
|
||||
|
||||
function uuid(suffix: number): string {
|
||||
return `00000000-0000-4000-8000-${suffix.toString().padStart(12, "0")}`;
|
||||
}
|
||||
14
services/diagnostics-api/tsconfig.json
Normal file
14
services/diagnostics-api/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["./worker-configuration.d.ts"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["worker-configuration.d.ts", "src/**/*.ts"]
|
||||
}
|
||||
23
services/diagnostics-api/vitest.config.ts
Normal file
23
services/diagnostics-api/vitest.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-pool-workers";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const migrationsPath = resolve(dirname(fileURLToPath(import.meta.url)), "migrations");
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
cloudflareTest(async () => ({
|
||||
wrangler: { configPath: "./wrangler.jsonc" },
|
||||
miniflare: {
|
||||
bindings: {
|
||||
INGEST_KEY: "test-ingest-key",
|
||||
TEST_MIGRATIONS: await readD1Migrations(migrationsPath),
|
||||
},
|
||||
},
|
||||
})),
|
||||
],
|
||||
test: {
|
||||
setupFiles: ["./test/apply-migrations.ts"],
|
||||
},
|
||||
});
|
||||
14719
services/diagnostics-api/worker-configuration.d.ts
vendored
Normal file
14719
services/diagnostics-api/worker-configuration.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
59
services/diagnostics-api/wrangler.jsonc
Normal file
59
services/diagnostics-api/wrangler.jsonc
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"$schema": "./node_modules/wrangler/config-schema.json",
|
||||
"name": "vnidrop-diagnostics",
|
||||
"main": "src/index.ts",
|
||||
"workers_dev": true,
|
||||
"preview_urls": false,
|
||||
"compatibility_date": "2026-07-14",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 0.1,
|
||||
},
|
||||
"triggers": {
|
||||
"crons": ["17 * * * *"],
|
||||
},
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "vnidrop-diagnostics",
|
||||
"database_id": "b9e18b17-d9fe-477b-8ace-2b1439d1694e",
|
||||
"migrations_dir": "migrations",
|
||||
},
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "BLOBS",
|
||||
"bucket_name": "vnidrop-diagnostics",
|
||||
"jurisdiction": "eu",
|
||||
},
|
||||
],
|
||||
"ratelimits": [
|
||||
{
|
||||
"name": "INSTALL_RATE_LIMITER",
|
||||
"namespace_id": "1001",
|
||||
"simple": {
|
||||
"limit": 30,
|
||||
"period": 60,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "SOURCE_RATE_LIMITER",
|
||||
"namespace_id": "1002",
|
||||
"simple": {
|
||||
"limit": 120,
|
||||
"period": 60,
|
||||
},
|
||||
},
|
||||
],
|
||||
// Optional: bind Analytics Engine as `AE` when event volume justifies it.
|
||||
// "analytics_engine_datasets": [
|
||||
// { "binding": "AE", "dataset": "vnidrop_events" },
|
||||
// ],
|
||||
"vars": {
|
||||
"MAX_BODY_BYTES": "262144",
|
||||
"MAX_EVENTS_PER_BATCH": "50",
|
||||
"RETENTION_DAYS": "90",
|
||||
},
|
||||
}
|
||||
@@ -37,6 +37,68 @@ plugins {
|
||||
alias(libs.plugins.kotlinAtomicfu)
|
||||
}
|
||||
|
||||
// Compile-time switches (gradle.properties or -P…).
|
||||
// included=false: no Share-diagnostics toggle, no telemetry/crash auto-upload stack.
|
||||
// endpoint/key both empty: transport is NoOp (safe default until Cloudflare is deployed).
|
||||
val diagnosticsIncluded: Boolean =
|
||||
(findProperty("vnidrop.diagnostics.included") as String?)?.toBooleanStrictOrNull() ?: true
|
||||
val diagnosticsEndpoint: String =
|
||||
(findProperty("vnidrop.diagnostics.endpoint") as String?)?.trim().orEmpty()
|
||||
val diagnosticsIngestKey: String =
|
||||
(findProperty("vnidrop.diagnostics.ingestKey") as String?)?.trim().orEmpty()
|
||||
check(diagnosticsEndpoint.isEmpty() == diagnosticsIngestKey.isEmpty()) {
|
||||
"vnidrop.diagnostics.endpoint and vnidrop.diagnostics.ingestKey must be configured together"
|
||||
}
|
||||
|
||||
val diagnosticsBuildConfigDir = layout.buildDirectory.dir("generated/diagnostics/commonMain/kotlin")
|
||||
val generateDiagnosticsBuildConfig by tasks.registering {
|
||||
group = "build"
|
||||
description = "Generates DiagnosticsBuildConfig from vnidrop.diagnostics.* properties"
|
||||
val outputDir = diagnosticsBuildConfigDir
|
||||
val included = diagnosticsIncluded
|
||||
val endpoint = diagnosticsEndpoint
|
||||
val ingestKey = diagnosticsIngestKey
|
||||
inputs.property("vnidrop.diagnostics.included", included)
|
||||
inputs.property("vnidrop.diagnostics.endpoint", endpoint)
|
||||
inputs.property("vnidrop.diagnostics.ingestKey", ingestKey)
|
||||
outputs.dir(outputDir)
|
||||
doLast {
|
||||
val packageDir = outputDir.get().asFile.resolve("com/vnidrop/app/diagnostics")
|
||||
packageDir.mkdirs()
|
||||
fun esc(value: String): String = buildString {
|
||||
for (ch in value) {
|
||||
when (ch) {
|
||||
'\\' -> append("\\\\")
|
||||
'"' -> append("\\\"")
|
||||
'\n' -> append("\\n")
|
||||
'\r' -> append("\\r")
|
||||
'\t' -> append("\\t")
|
||||
'$' -> append("\\$")
|
||||
else -> append(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
packageDir.resolve("DiagnosticsBuildConfig.kt").writeText(
|
||||
"""
|
||||
|package com.vnidrop.app.diagnostics
|
||||
|
|
||||
|/**
|
||||
| * Generated by shared/build.gradle.kts from vnidrop.diagnostics.* properties.
|
||||
| *
|
||||
| * - included=false → no opt-in UI / telemetry / crash hooks
|
||||
| * - endpoint/key both empty → [NoOpDiagnosticsTransport] (no network)
|
||||
| */
|
||||
|object DiagnosticsBuildConfig {
|
||||
| const val INCLUDED: Boolean = $included
|
||||
| const val ENDPOINT: String = "${esc(endpoint)}"
|
||||
| const val INGEST_KEY: String = "${esc(ingestKey)}"
|
||||
|}
|
||||
|
|
||||
""".trimMargin(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
if (GobleyHost.current.platform == GobleyHost.Platform.MacOS) {
|
||||
listOf(
|
||||
@@ -59,6 +121,9 @@ kotlin {
|
||||
jvm()
|
||||
|
||||
sourceSets {
|
||||
commonMain {
|
||||
kotlin.srcDir(files(diagnosticsBuildConfigDir).builtBy(generateDiagnosticsBuildConfig))
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
@@ -119,7 +184,7 @@ val hostCargoTargets = buildSet<RustTarget> {
|
||||
cargo {
|
||||
packageDirectory = layout.projectDirectory.dir("../crates/vnidrop")
|
||||
publishJvmArtifacts = true
|
||||
androidTargetsToBuild.set(setOf(RustAndroidTarget.Arm64))
|
||||
androidTargetsToBuild.set(setOf(RustAndroidTarget.Arm64, RustAndroidTarget.X64))
|
||||
builds.jvm {
|
||||
variants {
|
||||
// Desktop distributions are built per host. Do not publish disabled
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
actual fun createPendingCrashStore(appDataDir: String): PendingCrashStore =
|
||||
AndroidPendingCrashStore(appDataDir)
|
||||
|
||||
private class AndroidPendingCrashStore(
|
||||
appDataDir: String,
|
||||
) : PendingCrashStore {
|
||||
private val directory = File(appDataDir, "diagnostics/crashes")
|
||||
|
||||
@Synchronized
|
||||
override fun write(report: CrashReport) {
|
||||
if (!isValidDiagnosticId(report.id)) return
|
||||
directory.mkdirs()
|
||||
val target = File(directory, "${report.id}.crash")
|
||||
val temporary = File(directory, ".${report.id}.tmp")
|
||||
val payload = CrashReportCodec.encode(report)
|
||||
temporary.writeText(payload, StandardCharsets.UTF_8)
|
||||
if (!temporary.renameTo(target)) {
|
||||
target.writeText(payload, StandardCharsets.UTF_8)
|
||||
temporary.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun list(): List<CrashReport> {
|
||||
if (!directory.isDirectory) return emptyList()
|
||||
return directory
|
||||
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
|
||||
.orEmpty()
|
||||
.sortedByDescending { it.lastModified() }
|
||||
.mapNotNull { file ->
|
||||
runCatching { CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun delete(id: String) {
|
||||
if (!isValidDiagnosticId(id)) return
|
||||
File(directory, "$id.crash").delete()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun prune(olderThanTimestampMillis: Long, maxCount: Int) {
|
||||
require(maxCount > 0) { "maxCount must be positive" }
|
||||
if (!directory.isDirectory) return
|
||||
directory.listFiles { file -> file.isFile && file.name.endsWith(".tmp") }
|
||||
.orEmpty()
|
||||
.forEach(File::delete)
|
||||
val reports = directory
|
||||
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
|
||||
.orEmpty()
|
||||
.mapNotNull { file ->
|
||||
val report = runCatching {
|
||||
CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8))
|
||||
}.getOrNull()
|
||||
if (report == null) {
|
||||
file.delete()
|
||||
null
|
||||
} else {
|
||||
file to report
|
||||
}
|
||||
}
|
||||
.sortedByDescending { (_, report) -> report.timestampMillis }
|
||||
reports.forEachIndexed { index, (file, report) ->
|
||||
if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
actual fun installPlatformCrashHook(onCrash: (Throwable) -> Unit) {
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
runCatching { onCrash(throwable) }
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URI
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
actual suspend fun platformHttpPost(
|
||||
url: String,
|
||||
headers: Map<String, String>,
|
||||
bodyUtf8: String,
|
||||
): PlatformHttpResponse = withContext(Dispatchers.IO) {
|
||||
val connection = (URI(url).toURL().openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
doOutput = true
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 30_000
|
||||
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
headers.forEach { (key, value) -> setRequestProperty(key, value) }
|
||||
}
|
||||
try {
|
||||
connection.outputStream.use { output ->
|
||||
output.write(bodyUtf8.toByteArray(StandardCharsets.UTF_8))
|
||||
}
|
||||
val code = connection.responseCode
|
||||
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
|
||||
val body = stream?.use { input ->
|
||||
BufferedReader(InputStreamReader(input, StandardCharsets.UTF_8)).readText()
|
||||
}.orEmpty()
|
||||
PlatformHttpResponse(code, body)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vnidrop.app.logging
|
||||
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore =
|
||||
@@ -37,6 +38,32 @@ private class AndroidPlatformLogStore(
|
||||
.map { file -> LogFileInfo(file.name, file.absolutePath, file.length(), file.lastModified()) }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun readLatest(maxBytes: Long): String {
|
||||
if (maxBytes <= 0) return ""
|
||||
directory.mkdirs()
|
||||
val files = listOf(activeFile) +
|
||||
(1..policy.maxFiles).map { File(directory, "app.$it.log") }
|
||||
val chunks = ArrayList<ByteArray>()
|
||||
var remaining = maxBytes
|
||||
for (file in files) {
|
||||
if (remaining <= 0 || !file.isFile) continue
|
||||
val slice = readTail(file, remaining)
|
||||
if (slice.isEmpty()) continue
|
||||
chunks.add(0, slice)
|
||||
remaining -= slice.size.toLong()
|
||||
}
|
||||
if (chunks.isEmpty()) return ""
|
||||
val total = chunks.sumOf { it.size }
|
||||
val out = ByteArray(total)
|
||||
var offset = 0
|
||||
for (chunk in chunks) {
|
||||
chunk.copyInto(out, offset)
|
||||
offset += chunk.size
|
||||
}
|
||||
return String(out, StandardCharsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun rotate() {
|
||||
if (policy.maxFiles == 0) {
|
||||
activeFile.delete()
|
||||
@@ -54,3 +81,22 @@ private class AndroidPlatformLogStore(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readTail(file: File, maxBytes: Long): ByteArray {
|
||||
if (!file.isFile || file.length() == 0L || maxBytes <= 0) return ByteArray(0)
|
||||
val length = file.length()
|
||||
val start = (length - maxBytes).coerceAtLeast(0L)
|
||||
val size = (length - start).toInt()
|
||||
RandomAccessFile(file, "r").use { raf ->
|
||||
raf.seek(start)
|
||||
val bytes = ByteArray(size)
|
||||
raf.readFully(bytes)
|
||||
if (start == 0L) return bytes
|
||||
val newline = bytes.indexOf('\n'.code.toByte())
|
||||
return if (newline in 0 until bytes.lastIndex) {
|
||||
bytes.copyOfRange(newline + 1, bytes.size)
|
||||
} else {
|
||||
bytes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,25 @@
|
||||
<string name="about_title">About</string>
|
||||
<string name="about_privacy">Privacy policy</string>
|
||||
<string name="about_bug_report">Report a bug</string>
|
||||
<string name="diagnostics_title">Share diagnostics</string>
|
||||
<string name="diagnostics_description">Send anonymous crash reports and usage events so we can improve VniDrop. You can turn this off anytime. Tickets, file paths, and transfer contents are never included.</string>
|
||||
<string name="diagnostics_enabled_message">Diagnostics sharing is on.</string>
|
||||
<string name="diagnostics_disabled_message">Diagnostics sharing is off.</string>
|
||||
<string name="bug_report_description">Tell us what went wrong. We attach device info and optional recent logs (with sensitive values redacted).</string>
|
||||
<string name="bug_report_what_label">What happened?</string>
|
||||
<string name="bug_report_expected_label">What did you expect?</string>
|
||||
<string name="bug_report_steps_label">Steps to reproduce (optional)</string>
|
||||
<string name="bug_report_contact_label">Contact email (optional)</string>
|
||||
<string name="bug_report_include_logs">Include recent logs</string>
|
||||
<string name="bug_report_include_logs_description">Helps us diagnose the issue. Sensitive values are redacted before sending.</string>
|
||||
<string name="bug_report_logs_size">Log attachment size</string>
|
||||
<string name="bug_report_device_section">Device information</string>
|
||||
<string name="bug_report_submit">Submit report</string>
|
||||
<string name="bug_report_submitting">Submitting…</string>
|
||||
<string name="bug_report_submitted">Thanks — your bug report was recorded.</string>
|
||||
<string name="bug_report_submit_failed">Could not submit the bug report. Try again later.</string>
|
||||
<string name="bug_report_missing_what">Please describe what happened.</string>
|
||||
<string name="bug_report_missing_expected">Please describe what you expected.</string>
|
||||
<string name="version_title">App version</string>
|
||||
<string name="device_name_title">Device name</string>
|
||||
<string name="device_model_title">Device model</string>
|
||||
|
||||
@@ -57,7 +57,13 @@ fun App(
|
||||
val graph = graphHolder.graph
|
||||
|
||||
val appViewModel = viewModel {
|
||||
AppViewModel(dependencies.environment, graph.coreRepository, graph.preferencesRepository, graph.messages)
|
||||
AppViewModel(
|
||||
dependencies.environment,
|
||||
graph.coreRepository,
|
||||
graph.preferencesRepository,
|
||||
graph.messages,
|
||||
graph.diagnostics,
|
||||
)
|
||||
}
|
||||
val sendViewModel = viewModel {
|
||||
SendViewModel(
|
||||
@@ -79,6 +85,8 @@ fun App(
|
||||
graph.preferencesRepository,
|
||||
dependencies.localNotificationService,
|
||||
graph.messages,
|
||||
graph.diagnostics.bugReports,
|
||||
graph.diagnostics,
|
||||
)
|
||||
}
|
||||
val appState by appViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.vnidrop.app
|
||||
|
||||
import com.vnidrop.app.core.CoreGateway
|
||||
import com.vnidrop.app.core.CoreRepository
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
|
||||
import com.vnidrop.app.diagnostics.createDiagnosticsTransport
|
||||
import com.vnidrop.app.feature.approvals.ApprovalCoordinator
|
||||
import com.vnidrop.app.feature.send.AppFilePreviewRepository
|
||||
import com.vnidrop.app.feature.send.createPlatformPreviewStore
|
||||
@@ -16,6 +18,9 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AppGraph(
|
||||
val dependencies: AppDependencies,
|
||||
@@ -34,6 +39,19 @@ class AppGraph(
|
||||
receiveFolder = dependencies.fileSystemService.defaultReceiveFolder(),
|
||||
themeMode = ThemeMode.System,
|
||||
notificationsEnabled = false,
|
||||
diagnosticsEnabled = false,
|
||||
),
|
||||
)
|
||||
val diagnostics = DiagnosticsCoordinator.create(
|
||||
appDataDir = dependencies.environment.defaultCoreDataDir,
|
||||
appVersion = dependencies.environment.appVersion,
|
||||
platform = dependencies.environment.name,
|
||||
preferencesRepository = preferencesRepository,
|
||||
scope = applicationScope,
|
||||
transport = createDiagnosticsTransport(
|
||||
appVersion = dependencies.environment.appVersion,
|
||||
platform = dependencies.environment.name,
|
||||
installIdProvider = { preferencesRepository.ensureDiagnosticsInstallId() },
|
||||
),
|
||||
)
|
||||
val approvalCoordinator = ApprovalCoordinator(
|
||||
@@ -47,6 +65,13 @@ class AppGraph(
|
||||
|
||||
init {
|
||||
AppLogger.initialize(dependencies.environment.defaultCoreDataDir)
|
||||
diagnostics.start()
|
||||
applicationScope.launch {
|
||||
visibility.isForeground
|
||||
.drop(1)
|
||||
.filter { isForeground -> !isForeground }
|
||||
.collect { diagnostics.telemetry.flush() }
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import com.vnidrop.app.logging.platformNowMillis
|
||||
import kotlin.concurrent.atomics.AtomicReference
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
|
||||
/**
|
||||
* Fixed-size ring of high-level app breadcrumbs for crash / bug context.
|
||||
* Always in-memory only; never auto-uploaded without policy + transport.
|
||||
*
|
||||
* Updates are best-effort under concurrency; losing a breadcrumb is preferable
|
||||
* to blocking a dying process on a lock.
|
||||
*/
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
class BreadcrumbBuffer(
|
||||
private val capacity: Int = DefaultCapacity,
|
||||
) {
|
||||
init {
|
||||
require(capacity > 0) { "capacity must be positive" }
|
||||
}
|
||||
|
||||
private val items = AtomicReference<List<Breadcrumb>>(emptyList())
|
||||
|
||||
fun add(name: String, properties: Map<String, String> = emptyMap(), timestampMillis: Long = platformNowMillis()) {
|
||||
val sanitizedName = sanitizeDiagnosticName(name)
|
||||
if (sanitizedName.isBlank()) return
|
||||
val crumb = Breadcrumb(
|
||||
name = sanitizedName,
|
||||
timestampMillis = timestampMillis,
|
||||
properties = sanitizeDiagnosticProperties(properties),
|
||||
)
|
||||
while (true) {
|
||||
val current = items.load()
|
||||
if (items.compareAndSet(current, (current + crumb).takeLast(capacity))) return
|
||||
}
|
||||
}
|
||||
|
||||
fun snapshot(): List<Breadcrumb> = items.load()
|
||||
|
||||
fun clear() {
|
||||
items.store(emptyList())
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DefaultCapacity = 40
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import com.vnidrop.app.DeviceInfo
|
||||
import com.vnidrop.app.logging.AppLogger
|
||||
import com.vnidrop.app.logging.platformNowMillis
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.util.randomUuidString
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
data class BugReportDraft(
|
||||
val whatHappened: String,
|
||||
val expected: String,
|
||||
val steps: String = "",
|
||||
val contact: String = "",
|
||||
val includeLogs: Boolean = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* User-initiated bug reports. Always allowed regardless of diagnostics opt-in.
|
||||
*/
|
||||
class BugReportService(
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
private val transport: DiagnosticsTransport,
|
||||
private val breadcrumbs: BreadcrumbBuffer,
|
||||
private val appVersion: String,
|
||||
private val platform: String,
|
||||
private val logReader: () -> String = {
|
||||
AppLogger.readLatestLogs(AppLogger.DefaultBugReportLogBytes)
|
||||
},
|
||||
) {
|
||||
fun assemble(
|
||||
draft: BugReportDraft,
|
||||
deviceInfo: DeviceInfo?,
|
||||
installId: String,
|
||||
): BugReport {
|
||||
val logs = if (draft.includeLogs) readReportLogs() else ""
|
||||
return BugReport(
|
||||
id = randomUuidString(),
|
||||
timestampMillis = platformNowMillis(),
|
||||
installId = sanitizeDiagnosticsInstallId(installId),
|
||||
appVersion = appVersion.takeUtf8Bytes(DiagnosticsJson.MaxAppVersionBytes),
|
||||
platform = platform.takeUtf8Bytes(DiagnosticsJson.MaxPlatformBytes),
|
||||
whatHappened = draft.whatHappened.trim().takeUtf8Bytes(MaxFieldBytes),
|
||||
expected = draft.expected.trim().takeUtf8Bytes(MaxFieldBytes),
|
||||
steps = draft.steps.trim().takeUtf8Bytes(MaxFieldBytes),
|
||||
contact = draft.contact.trim().takeUtf8Bytes(MaxContactBytes),
|
||||
includeLogs = draft.includeLogs,
|
||||
logs = logs,
|
||||
device = DeviceSnapshot(
|
||||
deviceName = deviceInfo?.deviceName?.takeUtf8Bytes(128),
|
||||
deviceModel = deviceInfo?.deviceModel?.takeUtf8Bytes(128),
|
||||
operatingSystem = (deviceInfo?.operatingSystem ?: platform).takeUtf8Bytes(192),
|
||||
network = deviceInfo?.network?.takeUtf8Bytes(96),
|
||||
batteryLevel = deviceInfo?.batteryLevel?.takeUtf8Bytes(64),
|
||||
),
|
||||
breadcrumbs = breadcrumbs.snapshot(),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun submit(draft: BugReportDraft, deviceInfo: DeviceInfo?): Result<BugReport> {
|
||||
val what = draft.whatHappened.trim()
|
||||
val expected = draft.expected.trim()
|
||||
if (what.isEmpty()) {
|
||||
return Result.failure(IllegalArgumentException("Describe what happened."))
|
||||
}
|
||||
if (expected.isEmpty()) {
|
||||
return Result.failure(IllegalArgumentException("Describe what you expected."))
|
||||
}
|
||||
val installId = preferencesRepository.ensureDiagnosticsInstallId()
|
||||
val report = assemble(draft, deviceInfo, installId)
|
||||
val send = try {
|
||||
transport.sendBugReport(report)
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (error: Throwable) {
|
||||
Result.failure(error)
|
||||
}
|
||||
return send.fold(
|
||||
onSuccess = {
|
||||
AppLogger.info("bug_report", "submitted", mapOf("id" to report.id))
|
||||
Result.success(report)
|
||||
},
|
||||
onFailure = { error ->
|
||||
AppLogger.error("bug_report", "submit failed", error)
|
||||
Result.failure(error)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun previewLogBytes(): Int = readReportLogs().encodeToByteArray().size
|
||||
|
||||
private fun readReportLogs(): String =
|
||||
LogRedactor.redact(logReader()).takeUtf8Bytes(MaxLogBytes)
|
||||
|
||||
companion object {
|
||||
internal const val MaxLogBytes = 192 * 1024
|
||||
private const val MaxFieldBytes = 4_000
|
||||
private const val MaxContactBytes = 320
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import com.vnidrop.app.logging.AppLogger
|
||||
import com.vnidrop.app.logging.platformNowMillis
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.util.randomUuidString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.concurrent.atomics.AtomicBoolean
|
||||
import kotlin.concurrent.atomics.AtomicReference
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
|
||||
/**
|
||||
* Captures uncaught exceptions to disk, then uploads on a later launch when
|
||||
* diagnostics is enabled (and when a real [DiagnosticsTransport] is wired).
|
||||
*/
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
class CrashReporter(
|
||||
private val store: PendingCrashStore,
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
private val transport: DiagnosticsTransport,
|
||||
private val breadcrumbs: BreadcrumbBuffer,
|
||||
private val appVersion: String,
|
||||
private val platform: String,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val installed = AtomicBoolean(false)
|
||||
private val observingPreferences = AtomicBoolean(false)
|
||||
private val capturePolicy = AtomicReference(CrashCapturePolicy())
|
||||
|
||||
fun startObservingPreferences() {
|
||||
if (!observingPreferences.compareAndSet(false, true)) return
|
||||
scope.launch {
|
||||
preferencesRepository.preferences.collect { prefs ->
|
||||
capturePolicy.store(
|
||||
CrashCapturePolicy(
|
||||
installId = prefs.diagnosticsInstallId,
|
||||
diagnosticsEnabled = prefs.diagnosticsEnabled,
|
||||
),
|
||||
)
|
||||
if (!prefs.diagnosticsEnabled) {
|
||||
runCatching(::deleteAllPending)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun installUnhandledExceptionHandler() {
|
||||
if (!DiagnosticsBuildConfig.INCLUDED) return
|
||||
if (!installed.compareAndSet(false, true)) return
|
||||
installPlatformCrashHook { throwable ->
|
||||
capture(throwable)
|
||||
}
|
||||
}
|
||||
|
||||
fun capture(throwable: Throwable, diagnosticsEnabledOverride: Boolean? = null): CrashReport {
|
||||
val policy = capturePolicy.load()
|
||||
val report = CrashReport(
|
||||
id = randomUuidString(),
|
||||
timestampMillis = platformNowMillis(),
|
||||
installId = sanitizeDiagnosticsInstallId(policy.installId),
|
||||
appVersion = appVersion.takeUtf8Bytes(DiagnosticsJson.MaxAppVersionBytes),
|
||||
platform = platform.takeUtf8Bytes(DiagnosticsJson.MaxPlatformBytes),
|
||||
exceptionType = throwable::class.simpleName ?: "Throwable",
|
||||
exceptionMessage = LogRedactor.redact(throwable.message.orEmpty()).takeUtf8Bytes(MaxMessageBytes),
|
||||
stackTrace = LogRedactor.redact(throwable.stackTraceToString()).takeUtf8Bytes(MaxStackBytes),
|
||||
breadcrumbs = breadcrumbs.snapshot(),
|
||||
diagnosticsEnabledAtCapture = diagnosticsEnabledOverride ?: policy.diagnosticsEnabled,
|
||||
)
|
||||
if (report.diagnosticsEnabledAtCapture != false) {
|
||||
runCatching { store.write(report) }
|
||||
runCatching {
|
||||
store.prune(
|
||||
olderThanTimestampMillis = platformNowMillis() - LocalRetentionMillis,
|
||||
maxCount = MaxLocalCrashCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
AppLogger.error("crash", "captured crash ${report.id}", throwable)
|
||||
return report
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads pending crashes that were captured with diagnostics enabled.
|
||||
* Local files are deleted after successful delivery or bounded by local retention.
|
||||
*/
|
||||
suspend fun flushPending() {
|
||||
store.prune(
|
||||
olderThanTimestampMillis = platformNowMillis() - LocalRetentionMillis,
|
||||
maxCount = MaxLocalCrashCount,
|
||||
)
|
||||
for (report in store.list()) {
|
||||
val preferences = preferencesRepository.preferences.first()
|
||||
if (!preferences.diagnosticsEnabled || capturePolicy.load().diagnosticsEnabled == false) {
|
||||
deleteAllPending()
|
||||
return
|
||||
}
|
||||
if (report.diagnosticsEnabledAtCapture == false) {
|
||||
store.delete(report.id)
|
||||
continue
|
||||
}
|
||||
val installId = sanitizeDiagnosticsInstallId(
|
||||
preferences.diagnosticsInstallId.ifBlank {
|
||||
preferencesRepository.ensureDiagnosticsInstallId()
|
||||
},
|
||||
)
|
||||
val resolved = report.copy(
|
||||
installId = report.installId.ifBlank { installId },
|
||||
diagnosticsEnabledAtCapture = true,
|
||||
)
|
||||
if (resolved != report) store.write(resolved)
|
||||
if (
|
||||
!preferencesRepository.preferences.first().diagnosticsEnabled ||
|
||||
capturePolicy.load().diagnosticsEnabled == false
|
||||
) {
|
||||
deleteAllPending()
|
||||
return
|
||||
}
|
||||
val result = try {
|
||||
transport.sendCrash(resolved)
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (error: Throwable) {
|
||||
Result.failure(error)
|
||||
}
|
||||
if (result.isSuccess) {
|
||||
store.delete(resolved.id)
|
||||
continue
|
||||
}
|
||||
val error = result.exceptionOrNull()
|
||||
if (error?.isPermanentDiagnosticsPayloadRejection() == true) {
|
||||
store.delete(resolved.id)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteAllPending() {
|
||||
store.list().forEach { report -> store.delete(report.id) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MaxMessageBytes = 2_000
|
||||
private const val MaxStackBytes = 32_000
|
||||
private const val MaxLocalCrashCount = 20
|
||||
private const val LocalRetentionMillis = 30L * 86_400_000L
|
||||
}
|
||||
}
|
||||
|
||||
private data class CrashCapturePolicy(
|
||||
val installId: String = "",
|
||||
val diagnosticsEnabled: Boolean? = null,
|
||||
)
|
||||
|
||||
expect fun installPlatformCrashHook(onCrash: (Throwable) -> Unit)
|
||||
@@ -0,0 +1,200 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
private const val MaxDiagnosticsAcknowledgementBytes = 16 * 1024
|
||||
private const val MaxDiagnosticsAcknowledgementDepth = 32
|
||||
|
||||
internal fun String.isSuccessfulDiagnosticsAcknowledgement(expectedId: String): Boolean {
|
||||
if (length > MaxDiagnosticsAcknowledgementBytes || encodeToByteArray().size > MaxDiagnosticsAcknowledgementBytes) return false
|
||||
return runCatching {
|
||||
DiagnosticsAcknowledgementParser(this).isSuccessful(expectedId.lowercase())
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private class DiagnosticsAcknowledgementParser(private val source: String) {
|
||||
private var index = 0
|
||||
|
||||
fun isSuccessful(expectedId: String): Boolean {
|
||||
skipWhitespace()
|
||||
expect('{')
|
||||
skipWhitespace()
|
||||
val keys = mutableSetOf<String>()
|
||||
var ok: Boolean? = null
|
||||
var id: String? = null
|
||||
if (!consume('}')) {
|
||||
while (true) {
|
||||
val key = parseString()
|
||||
require(keys.add(key)) { "duplicate JSON object key" }
|
||||
skipWhitespace()
|
||||
expect(':')
|
||||
skipWhitespace()
|
||||
when (key) {
|
||||
"ok" -> ok = parseBoolean()
|
||||
"id" -> id = parseString()
|
||||
else -> skipValue(depth = 1)
|
||||
}
|
||||
skipWhitespace()
|
||||
when {
|
||||
consume('}') -> break
|
||||
consume(',') -> {
|
||||
skipWhitespace()
|
||||
require(peek() != '}') { "trailing JSON object comma" }
|
||||
}
|
||||
else -> error("expected JSON object separator")
|
||||
}
|
||||
}
|
||||
}
|
||||
skipWhitespace()
|
||||
require(index == source.length) { "unexpected data after JSON object" }
|
||||
return ok == true && id == expectedId
|
||||
}
|
||||
|
||||
private fun skipValue(depth: Int) {
|
||||
require(depth <= MaxDiagnosticsAcknowledgementDepth) { "JSON nesting is too deep" }
|
||||
when (peek()) {
|
||||
'"' -> parseString()
|
||||
'{' -> skipObject(depth)
|
||||
'[' -> skipArray(depth)
|
||||
't' -> expectLiteral("true")
|
||||
'f' -> expectLiteral("false")
|
||||
'n' -> expectLiteral("null")
|
||||
'-' -> skipNumber()
|
||||
in '0'..'9' -> skipNumber()
|
||||
else -> error("invalid JSON value")
|
||||
}
|
||||
}
|
||||
|
||||
private fun skipObject(depth: Int) {
|
||||
expect('{')
|
||||
skipWhitespace()
|
||||
if (consume('}')) return
|
||||
while (true) {
|
||||
parseString()
|
||||
skipWhitespace()
|
||||
expect(':')
|
||||
skipWhitespace()
|
||||
skipValue(depth + 1)
|
||||
skipWhitespace()
|
||||
when {
|
||||
consume('}') -> return
|
||||
consume(',') -> {
|
||||
skipWhitespace()
|
||||
require(peek() != '}') { "trailing JSON object comma" }
|
||||
}
|
||||
else -> error("expected JSON object separator")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun skipArray(depth: Int) {
|
||||
expect('[')
|
||||
skipWhitespace()
|
||||
if (consume(']')) return
|
||||
while (true) {
|
||||
skipValue(depth + 1)
|
||||
skipWhitespace()
|
||||
when {
|
||||
consume(']') -> return
|
||||
consume(',') -> {
|
||||
skipWhitespace()
|
||||
require(peek() != ']') { "trailing JSON array comma" }
|
||||
}
|
||||
else -> error("expected JSON array separator")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseBoolean(): Boolean = when {
|
||||
source.startsWith("true", index) -> {
|
||||
index += 4
|
||||
true
|
||||
}
|
||||
source.startsWith("false", index) -> {
|
||||
index += 5
|
||||
false
|
||||
}
|
||||
else -> error("expected JSON boolean")
|
||||
}
|
||||
|
||||
private fun parseString(): String {
|
||||
expect('"')
|
||||
val value = StringBuilder()
|
||||
while (index < source.length) {
|
||||
val char = source[index++]
|
||||
when {
|
||||
char == '"' -> return value.toString()
|
||||
char == '\\' -> value.append(parseEscape())
|
||||
char.code < 0x20 -> error("unescaped JSON control character")
|
||||
else -> value.append(char)
|
||||
}
|
||||
}
|
||||
error("unterminated JSON string")
|
||||
}
|
||||
|
||||
private fun parseEscape(): Char {
|
||||
require(index < source.length) { "unterminated JSON escape" }
|
||||
return when (val escaped = source[index++]) {
|
||||
'"', '\\', '/' -> escaped
|
||||
'b' -> '\b'
|
||||
'f' -> '\u000c'
|
||||
'n' -> '\n'
|
||||
'r' -> '\r'
|
||||
't' -> '\t'
|
||||
'u' -> parseUnicodeEscape()
|
||||
else -> error("invalid JSON escape")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseUnicodeEscape(): Char {
|
||||
require(index + 4 <= source.length) { "incomplete JSON Unicode escape" }
|
||||
var value = 0
|
||||
repeat(4) {
|
||||
value = value * 16 + source[index++].digitToIntOrNull(16).let { digit ->
|
||||
requireNotNull(digit) { "invalid JSON Unicode escape" }
|
||||
}
|
||||
}
|
||||
return value.toChar()
|
||||
}
|
||||
|
||||
private fun skipNumber() {
|
||||
consume('-')
|
||||
when (peek()) {
|
||||
'0' -> {
|
||||
index += 1
|
||||
require(peek() !in '0'..'9') { "leading zero in JSON number" }
|
||||
}
|
||||
in '1'..'9' -> while (peek() in '0'..'9') index += 1
|
||||
else -> error("invalid JSON number")
|
||||
}
|
||||
if (consume('.')) {
|
||||
require(peek() in '0'..'9') { "invalid JSON fraction" }
|
||||
while (peek() in '0'..'9') index += 1
|
||||
}
|
||||
if (peek() == 'e' || peek() == 'E') {
|
||||
index += 1
|
||||
if (peek() == '+' || peek() == '-') index += 1
|
||||
require(peek() in '0'..'9') { "invalid JSON exponent" }
|
||||
while (peek() in '0'..'9') index += 1
|
||||
}
|
||||
}
|
||||
|
||||
private fun expectLiteral(literal: String) {
|
||||
require(source.startsWith(literal, index)) { "invalid JSON literal" }
|
||||
index += literal.length
|
||||
}
|
||||
|
||||
private fun skipWhitespace() {
|
||||
while (peek() == ' ' || peek() == '\t' || peek() == '\r' || peek() == '\n') index += 1
|
||||
}
|
||||
|
||||
private fun expect(expected: Char) {
|
||||
require(consume(expected)) { "expected '$expected'" }
|
||||
}
|
||||
|
||||
private fun consume(expected: Char): Boolean {
|
||||
if (peek() != expected) return false
|
||||
index += 1
|
||||
return true
|
||||
}
|
||||
|
||||
private fun peek(): Char? = source.getOrNull(index)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Owns diagnostics services for the app process: telemetry, crashes, bug reports.
|
||||
*
|
||||
* When [DiagnosticsBuildConfig.INCLUDED] is false (compile-time), telemetry and
|
||||
* crash auto-reporting are never started; [bugReports] still works for support.
|
||||
*/
|
||||
class DiagnosticsCoordinator(
|
||||
val preferencesRepository: PreferencesRepository,
|
||||
val transport: DiagnosticsTransport,
|
||||
val breadcrumbs: BreadcrumbBuffer,
|
||||
val telemetry: TelemetryRecorder,
|
||||
val crashReporter: CrashReporter,
|
||||
val bugReports: BugReportService,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
fun start() {
|
||||
// Install id is useful for bug-report correlation even without telemetry.
|
||||
scope.launch {
|
||||
preferencesRepository.ensureDiagnosticsInstallId()
|
||||
}
|
||||
if (!DiagnosticsBuildConfig.INCLUDED) return
|
||||
crashReporter.startObservingPreferences()
|
||||
crashReporter.installUnhandledExceptionHandler()
|
||||
scope.launch {
|
||||
crashReporter.flushPending()
|
||||
}
|
||||
}
|
||||
|
||||
fun record(name: String, properties: Map<String, String> = emptyMap()) {
|
||||
if (!DiagnosticsBuildConfig.INCLUDED) return
|
||||
telemetry.record(name, properties)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
appDataDir: String,
|
||||
appVersion: String,
|
||||
platform: String,
|
||||
preferencesRepository: PreferencesRepository,
|
||||
scope: CoroutineScope,
|
||||
transport: DiagnosticsTransport = NoOpDiagnosticsTransport(),
|
||||
): DiagnosticsCoordinator {
|
||||
val breadcrumbs = BreadcrumbBuffer()
|
||||
val crashStore = createPendingCrashStore(appDataDir)
|
||||
val telemetry = TelemetryRecorder(
|
||||
preferencesRepository = preferencesRepository,
|
||||
transport = transport,
|
||||
breadcrumbs = breadcrumbs,
|
||||
scope = scope,
|
||||
)
|
||||
val crashReporter = CrashReporter(
|
||||
store = crashStore,
|
||||
preferencesRepository = preferencesRepository,
|
||||
transport = transport,
|
||||
breadcrumbs = breadcrumbs,
|
||||
appVersion = appVersion,
|
||||
platform = platform,
|
||||
scope = scope,
|
||||
)
|
||||
val bugReports = BugReportService(
|
||||
preferencesRepository = preferencesRepository,
|
||||
transport = transport,
|
||||
breadcrumbs = breadcrumbs,
|
||||
appVersion = appVersion,
|
||||
platform = platform,
|
||||
)
|
||||
return DiagnosticsCoordinator(
|
||||
preferencesRepository = preferencesRepository,
|
||||
transport = transport,
|
||||
breadcrumbs = breadcrumbs,
|
||||
telemetry = telemetry,
|
||||
crashReporter = crashReporter,
|
||||
bugReports = bugReports,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
/**
|
||||
* Minimal JSON encoding for diagnostics payloads (no kotlinx.serialization dependency).
|
||||
*/
|
||||
internal object DiagnosticsJson {
|
||||
internal const val MaxRequestBytes = 256 * 1024
|
||||
internal const val MaxInstallIdBytes = 80
|
||||
internal const val MaxAppVersionBytes = 40
|
||||
internal const val MaxPlatformBytes = 40
|
||||
private const val MaxBreadcrumbsJsonBytes = 16_000
|
||||
private const val MaxBreadcrumbs = 40
|
||||
private const val SizedBatchId = "00000000-0000-4000-8000-000000000000"
|
||||
|
||||
fun eventsBody(
|
||||
batchId: String,
|
||||
installId: String,
|
||||
appVersion: String,
|
||||
platform: String,
|
||||
events: List<TelemetryEvent>,
|
||||
): String = buildString {
|
||||
append('{')
|
||||
appendJsonField("batchId", batchId)
|
||||
append(',')
|
||||
appendJsonField("installId", installId)
|
||||
append(',')
|
||||
appendJsonField("appVersion", appVersion)
|
||||
append(',')
|
||||
appendJsonField("platform", platform)
|
||||
append(',')
|
||||
append("\"events\":[")
|
||||
events.forEachIndexed { index, event ->
|
||||
if (index > 0) append(',')
|
||||
append('{')
|
||||
appendJsonField("name", event.name)
|
||||
append(',')
|
||||
append("\"timestampMillis\":")
|
||||
append(event.timestampMillis)
|
||||
append(',')
|
||||
append("\"schemaVersion\":")
|
||||
append(event.schemaVersion)
|
||||
append(',')
|
||||
append("\"properties\":")
|
||||
appendStringMap(event.properties)
|
||||
append('}')
|
||||
}
|
||||
append("]}")
|
||||
}
|
||||
|
||||
fun eventBatchFitsRequest(events: List<TelemetryEvent>): Boolean =
|
||||
eventsBody(
|
||||
batchId = SizedBatchId,
|
||||
installId = "\u0000".repeat(MaxInstallIdBytes),
|
||||
appVersion = "\u0000".repeat(MaxAppVersionBytes),
|
||||
platform = "\u0000".repeat(MaxPlatformBytes),
|
||||
events = events,
|
||||
).encodeToByteArray().size <= MaxRequestBytes
|
||||
|
||||
fun crashBody(report: CrashReport): String = buildString {
|
||||
append('{')
|
||||
appendJsonField("id", report.id)
|
||||
append(',')
|
||||
append("\"timestampMillis\":")
|
||||
append(report.timestampMillis)
|
||||
append(',')
|
||||
appendJsonField("installId", report.installId)
|
||||
append(',')
|
||||
appendJsonField("appVersion", report.appVersion)
|
||||
append(',')
|
||||
appendJsonField("platform", report.platform)
|
||||
append(',')
|
||||
appendJsonField("exceptionType", report.exceptionType)
|
||||
append(',')
|
||||
appendJsonField("exceptionMessage", report.exceptionMessage)
|
||||
append(',')
|
||||
appendJsonField("stackTrace", report.stackTrace)
|
||||
append(',')
|
||||
append("\"diagnosticsEnabledAtCapture\":")
|
||||
append(requireNotNull(report.diagnosticsEnabledAtCapture) {
|
||||
"crash consent must be resolved before delivery"
|
||||
})
|
||||
append(',')
|
||||
append("\"schemaVersion\":")
|
||||
append(report.schemaVersion)
|
||||
append(',')
|
||||
append("\"breadcrumbs\":")
|
||||
appendBreadcrumbs(report.breadcrumbs)
|
||||
append('}')
|
||||
}
|
||||
|
||||
fun bugBody(report: BugReport): String {
|
||||
val logs = if (report.includeLogs) report.logs else ""
|
||||
val complete = buildBugBody(report, logs)
|
||||
if (complete.encodeToByteArray().size <= MaxRequestBytes || logs.isEmpty()) return complete
|
||||
|
||||
var best = buildBugBody(report, "")
|
||||
if (best.encodeToByteArray().size > MaxRequestBytes) return best
|
||||
var minimumBytes = 0
|
||||
var maximumBytes = logs.encodeToByteArray().size
|
||||
while (minimumBytes <= maximumBytes) {
|
||||
val candidateBytes = minimumBytes + (maximumBytes - minimumBytes) / 2
|
||||
val candidate = buildBugBody(report, logs.takeUtf8Bytes(candidateBytes))
|
||||
if (candidate.encodeToByteArray().size <= MaxRequestBytes) {
|
||||
best = candidate
|
||||
minimumBytes = candidateBytes + 1
|
||||
} else {
|
||||
maximumBytes = candidateBytes - 1
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
private fun buildBugBody(report: BugReport, logs: String): String = buildString {
|
||||
append('{')
|
||||
appendJsonField("id", report.id)
|
||||
append(',')
|
||||
append("\"timestampMillis\":")
|
||||
append(report.timestampMillis)
|
||||
append(',')
|
||||
appendJsonField("installId", report.installId)
|
||||
append(',')
|
||||
appendJsonField("appVersion", report.appVersion)
|
||||
append(',')
|
||||
appendJsonField("platform", report.platform)
|
||||
append(',')
|
||||
appendJsonField("whatHappened", report.whatHappened)
|
||||
append(',')
|
||||
appendJsonField("expected", report.expected)
|
||||
append(',')
|
||||
appendJsonField("steps", report.steps)
|
||||
append(',')
|
||||
appendJsonField("contact", report.contact)
|
||||
append(',')
|
||||
append("\"includeLogs\":")
|
||||
append(report.includeLogs)
|
||||
append(',')
|
||||
appendJsonField("logs", logs)
|
||||
append(',')
|
||||
append("\"schemaVersion\":")
|
||||
append(report.schemaVersion)
|
||||
append(',')
|
||||
append("\"device\":{")
|
||||
appendJsonField("deviceName", report.device.deviceName.orEmpty())
|
||||
append(',')
|
||||
appendJsonField("deviceModel", report.device.deviceModel.orEmpty())
|
||||
append(',')
|
||||
appendJsonField("operatingSystem", report.device.operatingSystem)
|
||||
append(',')
|
||||
appendJsonField("network", report.device.network.orEmpty())
|
||||
append(',')
|
||||
appendJsonField("batteryLevel", report.device.batteryLevel.orEmpty())
|
||||
append("},")
|
||||
append("\"breadcrumbs\":")
|
||||
appendBreadcrumbs(report.breadcrumbs)
|
||||
append('}')
|
||||
}
|
||||
|
||||
private fun StringBuilder.appendBreadcrumbs(crumbs: List<Breadcrumb>) {
|
||||
append('[')
|
||||
var encodedBytes = 2
|
||||
var appended = 0
|
||||
for (crumb in crumbs) {
|
||||
if (appended == MaxBreadcrumbs) break
|
||||
val name = sanitizeDiagnosticName(crumb.name)
|
||||
if (name.isBlank() || crumb.timestampMillis < 0) continue
|
||||
val encoded = buildString {
|
||||
append('{')
|
||||
appendJsonField("name", name)
|
||||
append(',')
|
||||
append("\"timestampMillis\":")
|
||||
append(crumb.timestampMillis)
|
||||
append(',')
|
||||
append("\"properties\":")
|
||||
appendStringMap(crumb.properties)
|
||||
append('}')
|
||||
}
|
||||
val additionBytes = encoded.encodeToByteArray().size + if (appended == 0) 0 else 1
|
||||
if (encodedBytes + additionBytes > MaxBreadcrumbsJsonBytes) break
|
||||
if (appended > 0) append(',')
|
||||
append(encoded)
|
||||
encodedBytes += additionBytes
|
||||
appended += 1
|
||||
}
|
||||
append(']')
|
||||
}
|
||||
|
||||
private fun StringBuilder.appendStringMap(map: Map<String, String>) {
|
||||
append('{')
|
||||
sanitizeDiagnosticProperties(map).entries.forEachIndexed { index, (key, value) ->
|
||||
if (index > 0) append(',')
|
||||
appendJsonField(key, value)
|
||||
}
|
||||
append('}')
|
||||
}
|
||||
|
||||
private fun StringBuilder.appendJsonField(key: String, value: String) {
|
||||
append('"')
|
||||
append(escape(key))
|
||||
append("\":\"")
|
||||
append(escape(value))
|
||||
append('"')
|
||||
}
|
||||
|
||||
internal fun escape(raw: String): String = buildString(raw.length + 8) {
|
||||
for (ch in raw) {
|
||||
when (ch) {
|
||||
'\\' -> append("\\\\")
|
||||
'"' -> append("\\\"")
|
||||
'\n' -> append("\\n")
|
||||
'\r' -> append("\\r")
|
||||
'\t' -> append("\\t")
|
||||
else -> if (ch.code < 0x20) {
|
||||
append("\\u")
|
||||
append(ch.code.toString(16).padStart(4, '0'))
|
||||
} else {
|
||||
append(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
/**
|
||||
* Client-side diagnostics payloads. Transport to Cloudflare (or elsewhere) is
|
||||
* intentionally abstracted; nothing here assumes a network backend.
|
||||
*/
|
||||
|
||||
data class TelemetryEvent(
|
||||
val name: String,
|
||||
val timestampMillis: Long,
|
||||
val properties: Map<String, String> = emptyMap(),
|
||||
val schemaVersion: Int = DiagnosticsSchemaVersion,
|
||||
)
|
||||
|
||||
/** One idempotent upload unit; [id] remains stable when delivery is retried. */
|
||||
data class TelemetryBatch(
|
||||
val id: String,
|
||||
val events: List<TelemetryEvent>,
|
||||
)
|
||||
|
||||
data class Breadcrumb(
|
||||
val name: String,
|
||||
val timestampMillis: Long,
|
||||
val properties: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
data class CrashReport(
|
||||
val id: String,
|
||||
val timestampMillis: Long,
|
||||
val installId: String,
|
||||
val appVersion: String,
|
||||
val platform: String,
|
||||
val exceptionType: String,
|
||||
val exceptionMessage: String,
|
||||
val stackTrace: String,
|
||||
val breadcrumbs: List<Breadcrumb>,
|
||||
/** `null` only while a startup crash is waiting for the persisted preference to load. */
|
||||
val diagnosticsEnabledAtCapture: Boolean?,
|
||||
val schemaVersion: Int = DiagnosticsSchemaVersion,
|
||||
)
|
||||
|
||||
data class DeviceSnapshot(
|
||||
val deviceName: String?,
|
||||
val deviceModel: String?,
|
||||
val operatingSystem: String,
|
||||
val network: String?,
|
||||
val batteryLevel: String?,
|
||||
)
|
||||
|
||||
data class BugReport(
|
||||
val id: String,
|
||||
val timestampMillis: Long,
|
||||
val installId: String,
|
||||
val appVersion: String,
|
||||
val platform: String,
|
||||
val whatHappened: String,
|
||||
val expected: String,
|
||||
val steps: String,
|
||||
val contact: String,
|
||||
val includeLogs: Boolean,
|
||||
val logs: String,
|
||||
val device: DeviceSnapshot,
|
||||
val breadcrumbs: List<Breadcrumb>,
|
||||
val schemaVersion: Int = DiagnosticsSchemaVersion,
|
||||
)
|
||||
|
||||
const val DiagnosticsSchemaVersion: Int = 1
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
internal const val MaxDiagnosticProperties = 12
|
||||
internal const val MaxDiagnosticPropertyKeyBytes = 40
|
||||
internal const val MaxDiagnosticPropertyValueBytes = 128
|
||||
internal const val MaxDiagnosticNameBytes = 64
|
||||
|
||||
internal fun sanitizeDiagnosticsInstallId(value: String): String {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.any { it.code < 0x20 || it.code == 0x7f }) return ""
|
||||
return trimmed.takeUtf8Bytes(DiagnosticsJson.MaxInstallIdBytes)
|
||||
}
|
||||
|
||||
internal fun sanitizeDiagnosticName(name: String): String =
|
||||
name.takeUtf8Bytes(MaxDiagnosticNameBytes)
|
||||
|
||||
internal fun sanitizeDiagnosticProperties(properties: Map<String, String>): Map<String, String> {
|
||||
val sanitized = LinkedHashMap<String, String>(minOf(properties.size, MaxDiagnosticProperties))
|
||||
for ((rawKey, rawValue) in properties) {
|
||||
val key = rawKey.takeUtf8Bytes(MaxDiagnosticPropertyKeyBytes)
|
||||
if (key.isEmpty() || key in sanitized) continue
|
||||
sanitized[key] = LogRedactor.redact(rawValue).takeUtf8Bytes(MaxDiagnosticPropertyValueBytes)
|
||||
if (sanitized.size == MaxDiagnosticProperties) break
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
internal fun String.takeUtf8Bytes(maxBytes: Int): String {
|
||||
require(maxBytes >= 0) { "maxBytes must not be negative" }
|
||||
val encoded = encodeToByteArray()
|
||||
if (encoded.size <= maxBytes) return this
|
||||
for (endIndex in maxBytes downTo (maxBytes - 3).coerceAtLeast(0)) {
|
||||
val decoded = runCatching {
|
||||
encoded.decodeToString(0, endIndex, throwOnInvalidSequence = true)
|
||||
}.getOrNull()
|
||||
if (decoded != null) return decoded
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
/** Network boundary for diagnostics; keep batching and validation client-side. */
|
||||
interface DiagnosticsTransport {
|
||||
suspend fun sendEvents(batch: TelemetryBatch): Result<Unit>
|
||||
suspend fun sendCrash(report: CrashReport): Result<Unit>
|
||||
suspend fun sendBugReport(report: BugReport): Result<Unit>
|
||||
}
|
||||
|
||||
internal class DiagnosticsUnavailableException : IllegalStateException("diagnostics delivery is not configured")
|
||||
|
||||
internal fun Throwable.isPermanentDiagnosticsPayloadRejection(): Boolean =
|
||||
this is DiagnosticsPayloadException ||
|
||||
(this is DiagnosticsHttpException && statusCode in setOf(400, 413, 415, 422))
|
||||
|
||||
/** Fails delivery without leaving the device. Used until a remote endpoint is configured. */
|
||||
class NoOpDiagnosticsTransport : DiagnosticsTransport {
|
||||
override suspend fun sendEvents(batch: TelemetryBatch): Result<Unit> = unavailable()
|
||||
override suspend fun sendCrash(report: CrashReport): Result<Unit> = unavailable()
|
||||
override suspend fun sendBugReport(report: BugReport): Result<Unit> = unavailable()
|
||||
|
||||
private fun unavailable(): Result<Unit> = Result.failure(DiagnosticsUnavailableException())
|
||||
}
|
||||
|
||||
/**
|
||||
* Test double that records calls and can fail on demand.
|
||||
*/
|
||||
class RecordingDiagnosticsTransport : DiagnosticsTransport {
|
||||
val eventBatches = mutableListOf<TelemetryBatch>()
|
||||
val events: List<List<TelemetryEvent>>
|
||||
get() = eventBatches.map(TelemetryBatch::events)
|
||||
val crashes = mutableListOf<CrashReport>()
|
||||
val bugReports = mutableListOf<BugReport>()
|
||||
var eventsResult: Result<Unit> = Result.success(Unit)
|
||||
var crashResult: Result<Unit> = Result.success(Unit)
|
||||
var bugResult: Result<Unit> = Result.success(Unit)
|
||||
|
||||
override suspend fun sendEvents(batch: TelemetryBatch): Result<Unit> {
|
||||
eventBatches += batch
|
||||
return eventsResult
|
||||
}
|
||||
|
||||
override suspend fun sendCrash(report: CrashReport): Result<Unit> {
|
||||
crashes += report
|
||||
return crashResult
|
||||
}
|
||||
|
||||
override suspend fun sendBugReport(report: BugReport): Result<Unit> {
|
||||
bugReports += report
|
||||
return bugResult
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import com.vnidrop.app.logging.AppLogger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* HTTPS client for the Cloudflare diagnostics Worker.
|
||||
* No-ops are preferred when [baseUrl] is blank — see [createDiagnosticsTransport].
|
||||
*/
|
||||
class HttpDiagnosticsTransport(
|
||||
baseUrl: String,
|
||||
private val ingestKey: String,
|
||||
private val appVersion: String = "",
|
||||
private val platform: String = "",
|
||||
private val installIdProvider: suspend () -> String = { "" },
|
||||
private val post: suspend (url: String, headers: Map<String, String>, body: String) -> PlatformHttpResponse =
|
||||
{ url, headers, body -> platformHttpPost(url, headers, body) },
|
||||
) : DiagnosticsTransport {
|
||||
private val root = baseUrl.trim().trimEnd('/')
|
||||
|
||||
init {
|
||||
require(root.isEmpty() || root.isAllowedDiagnosticsEndpoint()) {
|
||||
"diagnostics endpoint must use HTTPS unless it targets a loopback host"
|
||||
}
|
||||
require(root.isEmpty() || ingestKey.isNotBlank()) {
|
||||
"diagnostics ingest key must be configured when the endpoint is set"
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendEvents(batch: TelemetryBatch): Result<Unit> {
|
||||
if (batch.events.isEmpty()) return Result.success(Unit)
|
||||
if (batch.events.size > TelemetryRecorder.MaxEventsPerBatch) {
|
||||
return Result.failure(DiagnosticsPayloadException("diagnostics event batch is too large"))
|
||||
}
|
||||
if (batch.events.any { it.name.isBlank() || it.timestampMillis < 0 }) {
|
||||
return Result.failure(DiagnosticsPayloadException("diagnostics event batch is invalid"))
|
||||
}
|
||||
val installId = sanitizeDiagnosticsInstallId(installIdProvider())
|
||||
val body = DiagnosticsJson.eventsBody(
|
||||
batch.id,
|
||||
installId,
|
||||
appVersion.takeUtf8Bytes(DiagnosticsJson.MaxAppVersionBytes),
|
||||
platform.takeUtf8Bytes(DiagnosticsJson.MaxPlatformBytes),
|
||||
batch.events,
|
||||
)
|
||||
return postJson("/v1/events", body, installId, batch.id)
|
||||
}
|
||||
|
||||
override suspend fun sendCrash(report: CrashReport): Result<Unit> {
|
||||
if (report.diagnosticsEnabledAtCapture == null) {
|
||||
return Result.failure(DiagnosticsPayloadException("crash consent is unresolved"))
|
||||
}
|
||||
val body = DiagnosticsJson.crashBody(report)
|
||||
return postJson("/v1/crashes", body, report.installId, report.id)
|
||||
}
|
||||
|
||||
override suspend fun sendBugReport(report: BugReport): Result<Unit> {
|
||||
val body = DiagnosticsJson.bugBody(report)
|
||||
return postJson("/v1/bugs", body, report.installId, report.id)
|
||||
}
|
||||
|
||||
private suspend fun postJson(
|
||||
path: String,
|
||||
body: String,
|
||||
installId: String,
|
||||
expectedId: String,
|
||||
): Result<Unit> {
|
||||
if (root.isEmpty()) {
|
||||
return Result.failure(IllegalStateException("diagnostics endpoint is not configured"))
|
||||
}
|
||||
if (body.encodeToByteArray().size > DiagnosticsJson.MaxRequestBytes) {
|
||||
return Result.failure(DiagnosticsPayloadException("diagnostics $path payload is too large"))
|
||||
}
|
||||
return try {
|
||||
val response = post(
|
||||
"$root$path",
|
||||
mapOf(
|
||||
"X-VniDrop-Key" to ingestKey,
|
||||
"X-VniDrop-Install-Id" to installId,
|
||||
"Accept" to "application/json",
|
||||
),
|
||||
body,
|
||||
)
|
||||
if (response.statusCode !in 200..299) {
|
||||
AppLogger.warn(
|
||||
"diagnostics",
|
||||
"transport rejected $path",
|
||||
mapOf("status" to response.statusCode.toString()),
|
||||
)
|
||||
throw DiagnosticsHttpException(response.statusCode, path)
|
||||
}
|
||||
if (!response.body.isSuccessfulDiagnosticsAcknowledgement(expectedId)) {
|
||||
throw DiagnosticsProtocolException(path)
|
||||
}
|
||||
Result.success(Unit)
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (error: Throwable) {
|
||||
Result.failure(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class DiagnosticsHttpException(
|
||||
val statusCode: Int,
|
||||
path: String,
|
||||
) : IllegalStateException("diagnostics $path failed: HTTP $statusCode")
|
||||
|
||||
internal class DiagnosticsProtocolException(path: String) :
|
||||
IllegalStateException("diagnostics $path returned an invalid acknowledgement")
|
||||
|
||||
internal class DiagnosticsPayloadException(message: String) : IllegalArgumentException(message)
|
||||
|
||||
/**
|
||||
* Builds transport from compile-time config. Empty endpoint and key → [NoOpDiagnosticsTransport].
|
||||
*/
|
||||
fun createDiagnosticsTransport(
|
||||
appVersion: String,
|
||||
platform: String,
|
||||
installIdProvider: suspend () -> String,
|
||||
): DiagnosticsTransport = buildDiagnosticsTransport(
|
||||
endpoint = DiagnosticsBuildConfig.ENDPOINT,
|
||||
ingestKey = DiagnosticsBuildConfig.INGEST_KEY,
|
||||
appVersion = appVersion,
|
||||
platform = platform,
|
||||
installIdProvider = installIdProvider,
|
||||
)
|
||||
|
||||
internal fun buildDiagnosticsTransport(
|
||||
endpoint: String,
|
||||
ingestKey: String,
|
||||
appVersion: String,
|
||||
platform: String,
|
||||
installIdProvider: suspend () -> String,
|
||||
): DiagnosticsTransport {
|
||||
val normalizedEndpoint = endpoint.trim()
|
||||
val normalizedIngestKey = ingestKey.trim()
|
||||
if (normalizedEndpoint.isEmpty() && normalizedIngestKey.isEmpty()) return NoOpDiagnosticsTransport()
|
||||
check(normalizedEndpoint.isNotEmpty() && normalizedIngestKey.isNotEmpty()) {
|
||||
"diagnostics endpoint and ingest key must be configured together"
|
||||
}
|
||||
return HttpDiagnosticsTransport(
|
||||
baseUrl = normalizedEndpoint,
|
||||
ingestKey = normalizedIngestKey,
|
||||
appVersion = appVersion,
|
||||
platform = platform,
|
||||
installIdProvider = installIdProvider,
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.isAllowedDiagnosticsEndpoint(): Boolean {
|
||||
if (any(Char::isWhitespace) || '?' in this || '#' in this) return false
|
||||
val schemeSeparator = indexOf("://")
|
||||
if (schemeSeparator <= 0) return false
|
||||
val scheme = substring(0, schemeSeparator).lowercase()
|
||||
val authority = substring(schemeSeparator + 3).substringBefore('/')
|
||||
if (authority.isEmpty() || '@' in authority) return false
|
||||
val host = when {
|
||||
authority.startsWith('[') -> {
|
||||
val end = authority.indexOf(']')
|
||||
if (end <= 1) return false
|
||||
val suffix = authority.substring(end + 1)
|
||||
if (suffix.isNotEmpty() && !suffix.isValidPortSuffix()) return false
|
||||
authority.substring(1, end)
|
||||
}
|
||||
else -> {
|
||||
if (authority.count { it == ':' } > 1) return false
|
||||
val portSeparator = authority.indexOf(':')
|
||||
if (portSeparator >= 0 && !authority.substring(portSeparator).isValidPortSuffix()) return false
|
||||
authority.substringBefore(':')
|
||||
}
|
||||
}.lowercase()
|
||||
if (host.isEmpty()) return false
|
||||
if (scheme == "https") return true
|
||||
return scheme == "http" && host.isLoopbackHost()
|
||||
}
|
||||
|
||||
private fun String.isValidPortSuffix(): Boolean =
|
||||
startsWith(':') && drop(1).toIntOrNull() in 1..65_535
|
||||
|
||||
private fun String.isLoopbackHost(): Boolean {
|
||||
if (this == "localhost" || this == "::1") return true
|
||||
val octets = split('.')
|
||||
return octets.size == 4 &&
|
||||
octets.first() == "127" &&
|
||||
octets.all { it.toIntOrNull() in 0..255 }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
/**
|
||||
* Scrubs known-sensitive patterns from free-form diagnostics text before upload
|
||||
* or attachment. Defense in depth for tickets, endpoint-like ids, and paths.
|
||||
*/
|
||||
object LogRedactor {
|
||||
fun redact(input: String): String {
|
||||
if (input.isEmpty()) return input
|
||||
var result = input
|
||||
for (rule in Rules) {
|
||||
result = rule.regex.replace(result, rule.replacement)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun redactMap(fields: Map<String, String>): Map<String, String> =
|
||||
fields.mapValues { (_, value) -> redact(value) }
|
||||
|
||||
private data class Rule(val regex: Regex, val replacement: String)
|
||||
|
||||
private val Rules = listOf(
|
||||
// Blob tickets / long base64-ish tokens often appear near "ticket=".
|
||||
Rule(
|
||||
Regex("""(?i)(ticket\s*[=:]\s*)([A-Za-z0-9+/=_\-]{24,})"""),
|
||||
"$1[redacted-ticket]",
|
||||
),
|
||||
// iroh-style node/endpoint ids: long hex or base32-ish.
|
||||
Rule(
|
||||
Regex("""(?i)(endpoint[_-]?id\s*[=:]\s*)([A-Za-z0-9+/=_\-]{16,})"""),
|
||||
"$1[redacted-endpoint]",
|
||||
),
|
||||
Rule(
|
||||
Regex("""\b[0-9a-fA-F]{48,}\b"""),
|
||||
"[redacted-hex]",
|
||||
),
|
||||
// Absolute filesystem paths (Unix + Windows drive).
|
||||
Rule(
|
||||
Regex("""(?<![A-Za-z0-9_])(/[^\s:]+|[A-Za-z]:\\[^\s]+)"""),
|
||||
"[redacted-path]",
|
||||
),
|
||||
// content:// and file:// URIs.
|
||||
Rule(
|
||||
Regex("""(?i)\b((?:content|file|http|https)://[^\s]+)"""),
|
||||
"[redacted-uri]",
|
||||
),
|
||||
// SAF tree/document ids.
|
||||
Rule(
|
||||
Regex("""(?i)(document[_-]?id|tree[_-]?uri)\s*[=:]\s*\S+"""),
|
||||
"$1=[redacted]",
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
/**
|
||||
* Durable crash envelopes written during process death and read on next launch.
|
||||
* Encoding is a simple line-oriented format (no kotlinx.serialization dependency).
|
||||
*/
|
||||
interface PendingCrashStore {
|
||||
fun write(report: CrashReport)
|
||||
fun list(): List<CrashReport>
|
||||
fun delete(id: String)
|
||||
fun prune(olderThanTimestampMillis: Long, maxCount: Int)
|
||||
}
|
||||
|
||||
expect fun createPendingCrashStore(appDataDir: String): PendingCrashStore
|
||||
|
||||
internal object CrashReportCodec {
|
||||
private const val FieldSep = "\u001f"
|
||||
private const val RecordSep = "\u001e"
|
||||
private const val Version2Prefix = "vnidrop-crash-v2\n"
|
||||
private const val MaxEncodedChars = 512 * 1024
|
||||
|
||||
fun encode(report: CrashReport): String = buildString {
|
||||
fun field(key: String, value: String) {
|
||||
append(key)
|
||||
append('=')
|
||||
append(value.hexEncode())
|
||||
append('\n')
|
||||
}
|
||||
append(Version2Prefix)
|
||||
field("id", report.id)
|
||||
field("ts", report.timestampMillis.toString())
|
||||
field("install", report.installId)
|
||||
field("app", report.appVersion)
|
||||
field("platform", report.platform)
|
||||
field("type", report.exceptionType)
|
||||
field("message", report.exceptionMessage)
|
||||
field("stack", report.stackTrace)
|
||||
field(
|
||||
"diag",
|
||||
when (report.diagnosticsEnabledAtCapture) {
|
||||
true -> "1"
|
||||
false -> "0"
|
||||
null -> "u"
|
||||
},
|
||||
)
|
||||
field("schema", report.schemaVersion.toString())
|
||||
val breadcrumbs = report.breadcrumbs.take(40)
|
||||
field("crumb.count", breadcrumbs.size.toString())
|
||||
breadcrumbs.forEachIndexed { crumbIndex, crumb ->
|
||||
field("crumb.$crumbIndex.ts", crumb.timestampMillis.toString())
|
||||
field("crumb.$crumbIndex.name", crumb.name)
|
||||
val properties = crumb.properties.entries.take(MaxDiagnosticProperties)
|
||||
field("crumb.$crumbIndex.prop.count", properties.size.toString())
|
||||
properties.forEachIndexed { propertyIndex, (key, value) ->
|
||||
field("crumb.$crumbIndex.prop.$propertyIndex.key", key)
|
||||
field("crumb.$crumbIndex.prop.$propertyIndex.value", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun decode(raw: String): CrashReport? {
|
||||
if (raw.isBlank() || raw.length > MaxEncodedChars) return null
|
||||
return if (raw.startsWith(Version2Prefix)) decodeVersion2(raw) else decodeLegacy(raw)
|
||||
}
|
||||
|
||||
private fun decodeVersion2(raw: String): CrashReport? {
|
||||
val map = linkedMapOf<String, String>()
|
||||
for (part in raw.removePrefix(Version2Prefix).lineSequence()) {
|
||||
if (part.isEmpty()) continue
|
||||
val eq = part.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
val key = part.substring(0, eq)
|
||||
val value = part.substring(eq + 1).hexDecode() ?: return null
|
||||
map[key] = value
|
||||
}
|
||||
val crumbCount = map["crumb.count"]?.toIntOrNull()?.takeIf { it in 0..40 } ?: return null
|
||||
val crumbs = buildList {
|
||||
repeat(crumbCount) { crumbIndex ->
|
||||
val timestamp = map["crumb.$crumbIndex.ts"]
|
||||
?.toLongOrNull()
|
||||
?.takeIf { it >= 0 }
|
||||
?: return null
|
||||
val name = map["crumb.$crumbIndex.name"]?.takeIf { it.isNotBlank() } ?: return null
|
||||
val propertyCount = map["crumb.$crumbIndex.prop.count"]
|
||||
?.toIntOrNull()
|
||||
?.takeIf { it in 0..MaxDiagnosticProperties }
|
||||
?: return null
|
||||
val properties = buildMap {
|
||||
repeat(propertyCount) { propertyIndex ->
|
||||
val key = map["crumb.$crumbIndex.prop.$propertyIndex.key"] ?: return null
|
||||
val value = map["crumb.$crumbIndex.prop.$propertyIndex.value"] ?: return null
|
||||
put(key, value)
|
||||
}
|
||||
}
|
||||
add(Breadcrumb(name = name, timestampMillis = timestamp, properties = properties))
|
||||
}
|
||||
}
|
||||
return reportFromFields(map, crumbs)
|
||||
}
|
||||
|
||||
private fun decodeLegacy(raw: String): CrashReport? {
|
||||
val map = linkedMapOf<String, String>()
|
||||
for (part in raw.split(FieldSep)) {
|
||||
if (part.isEmpty()) continue
|
||||
val eq = part.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
val key = part.substring(0, eq)
|
||||
val value = part.substring(eq + 1)
|
||||
.replace("\\n", "\n")
|
||||
.replace("\\r", "\r")
|
||||
map[key] = value
|
||||
}
|
||||
val crumbs = map["crumbs"].orEmpty()
|
||||
.split(RecordSep)
|
||||
.filter { it.isNotBlank() }
|
||||
.mapNotNull { entry ->
|
||||
val pieces = entry.split('|', limit = 3)
|
||||
if (pieces.size < 2) return@mapNotNull null
|
||||
val ts = pieces[0].toLongOrNull()?.takeIf { it >= 0 } ?: return@mapNotNull null
|
||||
val name = pieces[1].takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
val props = if (pieces.size > 2 && pieces[2].isNotBlank()) {
|
||||
pieces[2].split(',').mapNotNull { kv ->
|
||||
val colon = kv.indexOf(':')
|
||||
if (colon <= 0) null
|
||||
else kv.substring(0, colon) to kv.substring(colon + 1)
|
||||
}.toMap()
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
Breadcrumb(name = name, timestampMillis = ts, properties = props)
|
||||
}
|
||||
return reportFromFields(map, crumbs)
|
||||
}
|
||||
|
||||
private fun reportFromFields(
|
||||
map: Map<String, String>,
|
||||
crumbs: List<Breadcrumb>,
|
||||
): CrashReport? {
|
||||
val id = map["id"]?.takeIf(::isValidDiagnosticId) ?: return null
|
||||
val timestamp = map["ts"]?.toLongOrNull()?.takeIf { it >= 0 } ?: return null
|
||||
val exceptionType = map["type"]?.takeIf { it.isNotBlank() } ?: return null
|
||||
val schemaVersion = map["schema"]?.toIntOrNull()
|
||||
?.takeIf { it == DiagnosticsSchemaVersion }
|
||||
?: return null
|
||||
val diagnosticsEnabled: Boolean? = when (map["diag"]) {
|
||||
"1" -> true
|
||||
"0" -> false
|
||||
"u" -> null
|
||||
else -> return null
|
||||
}
|
||||
return CrashReport(
|
||||
id = id,
|
||||
timestampMillis = timestamp,
|
||||
installId = map["install"].orEmpty(),
|
||||
appVersion = map["app"].orEmpty(),
|
||||
platform = map["platform"].orEmpty(),
|
||||
exceptionType = exceptionType,
|
||||
exceptionMessage = map["message"].orEmpty(),
|
||||
stackTrace = map["stack"].orEmpty(),
|
||||
breadcrumbs = crumbs,
|
||||
diagnosticsEnabledAtCapture = diagnosticsEnabled,
|
||||
schemaVersion = schemaVersion,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun isValidDiagnosticId(id: String): Boolean =
|
||||
DiagnosticIdPattern.matches(id)
|
||||
|
||||
private val DiagnosticIdPattern =
|
||||
Regex("^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$")
|
||||
|
||||
private fun String.hexEncode(): String {
|
||||
val digits = "0123456789abcdef"
|
||||
return buildString(length * 2) {
|
||||
for (byte in this@hexEncode.encodeToByteArray()) {
|
||||
val value = byte.toInt() and 0xff
|
||||
append(digits[value ushr 4])
|
||||
append(digits[value and 0x0f])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.hexDecode(): String? {
|
||||
if (length % 2 != 0) return null
|
||||
val bytes = ByteArray(length / 2)
|
||||
for (index in bytes.indices) {
|
||||
val high = this[index * 2].digitToIntOrNull(16) ?: return null
|
||||
val low = this[index * 2 + 1].digitToIntOrNull(16) ?: return null
|
||||
bytes[index] = ((high shl 4) or low).toByte()
|
||||
}
|
||||
return runCatching { bytes.decodeToString(throwOnInvalidSequence = true) }.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
data class PlatformHttpResponse(
|
||||
val statusCode: Int,
|
||||
val body: String,
|
||||
)
|
||||
|
||||
expect suspend fun platformHttpPost(
|
||||
url: String,
|
||||
headers: Map<String, String>,
|
||||
bodyUtf8: String,
|
||||
): PlatformHttpResponse
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import com.vnidrop.app.logging.platformNowMillis
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.util.randomUuidString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.concurrent.atomics.AtomicReference
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
|
||||
/**
|
||||
* Product telemetry: sparse events, gated by diagnostics opt-in.
|
||||
* Events are buffered and flushed in batches when transport is available.
|
||||
*/
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
class TelemetryRecorder(
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
private val transport: DiagnosticsTransport,
|
||||
private val breadcrumbs: BreadcrumbBuffer,
|
||||
private val scope: CoroutineScope,
|
||||
private val maxBufferSize: Int = DefaultMaxBuffer,
|
||||
private val flushThreshold: Int = DefaultFlushThreshold,
|
||||
private val flushIntervalMillis: Long = DefaultFlushIntervalMillis,
|
||||
private val retryBackoffMillis: Long = DefaultRetryBackoffMillis,
|
||||
private val automaticRetryCount: Int = DefaultAutomaticRetryCount,
|
||||
) {
|
||||
private val bufferMutex = Mutex()
|
||||
private val state = AtomicReference(TelemetryState())
|
||||
private val flushSignals = Channel<Unit>(Channel.CONFLATED)
|
||||
|
||||
init {
|
||||
require(maxBufferSize > 0) { "maxBufferSize must be positive" }
|
||||
require(flushThreshold > 0) { "flushThreshold must be positive" }
|
||||
require(flushIntervalMillis > 0) { "flushIntervalMillis must be positive" }
|
||||
require(retryBackoffMillis > 0) { "retryBackoffMillis must be positive" }
|
||||
require(automaticRetryCount >= 0) { "automaticRetryCount must not be negative" }
|
||||
scope.launch {
|
||||
preferencesRepository.preferences
|
||||
.map { it.diagnosticsEnabled }
|
||||
.distinctUntilChanged()
|
||||
.collect { isEnabled ->
|
||||
updateState { current ->
|
||||
if (isEnabled) current.copy(enabled = true) else TelemetryState(enabled = false)
|
||||
}
|
||||
flushSignals.trySend(Unit)
|
||||
}
|
||||
}
|
||||
scope.launch { runAutomaticFlushes() }
|
||||
}
|
||||
|
||||
fun record(name: String, properties: Map<String, String> = emptyMap()) {
|
||||
if (!DiagnosticsBuildConfig.INCLUDED) return
|
||||
val sanitizedName = sanitizeDiagnosticName(name)
|
||||
if (sanitizedName.isBlank()) return
|
||||
val sanitizedProperties = sanitizeDiagnosticProperties(properties)
|
||||
breadcrumbs.add(sanitizedName, sanitizedProperties)
|
||||
val event = TelemetryEvent(
|
||||
name = sanitizedName,
|
||||
timestampMillis = platformNowMillis(),
|
||||
properties = sanitizedProperties,
|
||||
)
|
||||
while (true) {
|
||||
val current = state.load()
|
||||
if (current.enabled == false) return
|
||||
val remainingCapacity =
|
||||
(maxBufferSize - current.retryBatch?.events.orEmpty().size).coerceAtLeast(0)
|
||||
val nextBuffer = (current.buffer + event).takeLast(remainingCapacity)
|
||||
if (state.compareAndSet(current, current.copy(buffer = nextBuffer))) {
|
||||
flushSignals.trySend(Unit)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun flush(): Result<Unit> {
|
||||
return bufferMutex.withLock {
|
||||
var discardedFailure: Throwable? = null
|
||||
var outcome: Result<Unit>? = null
|
||||
while (outcome == null) {
|
||||
val current = state.load()
|
||||
if (current.enabled != true) return@withLock Result.success(Unit)
|
||||
val pendingRetry = current.retryBatch
|
||||
val events = if (pendingRetry == null) nextBatchEvents(current.buffer) else emptyList()
|
||||
if (pendingRetry == null && events.isEmpty()) {
|
||||
outcome = discardedFailure?.let { Result.failure(it) } ?: Result.success(Unit)
|
||||
continue
|
||||
}
|
||||
val batch: TelemetryBatch
|
||||
if (pendingRetry != null) {
|
||||
batch = pendingRetry
|
||||
} else {
|
||||
val prepared = TelemetryBatch(id = randomUuidString(), events = events)
|
||||
val next = current.copy(
|
||||
buffer = current.buffer.drop(events.size),
|
||||
retryBatch = prepared,
|
||||
)
|
||||
if (!state.compareAndSet(current, next)) continue
|
||||
batch = prepared
|
||||
}
|
||||
if (state.load().retryBatch != batch) continue
|
||||
val result = try {
|
||||
transport.sendEvents(batch)
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (error: Throwable) {
|
||||
Result.failure(error)
|
||||
}
|
||||
if (result.isSuccess) {
|
||||
clearRetryBatch(batch)
|
||||
continue
|
||||
}
|
||||
val error = result.exceptionOrNull() ?: IllegalStateException("diagnostics event delivery failed")
|
||||
if (error.isPermanentDiagnosticsPayloadRejection()) {
|
||||
clearRetryBatch(batch)
|
||||
discardedFailure = discardedFailure ?: error
|
||||
continue
|
||||
}
|
||||
outcome = result
|
||||
}
|
||||
checkNotNull(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
private fun nextBatchEvents(events: List<TelemetryEvent>): List<TelemetryEvent> {
|
||||
if (events.isEmpty()) return emptyList()
|
||||
var minimum = 1
|
||||
var maximum = minOf(events.size, MaxEventsPerBatch)
|
||||
var accepted = 1
|
||||
while (minimum <= maximum) {
|
||||
val candidateSize = minimum + (maximum - minimum) / 2
|
||||
if (DiagnosticsJson.eventBatchFitsRequest(events.take(candidateSize))) {
|
||||
accepted = candidateSize
|
||||
minimum = candidateSize + 1
|
||||
} else {
|
||||
maximum = candidateSize - 1
|
||||
}
|
||||
}
|
||||
return events.take(accepted)
|
||||
}
|
||||
|
||||
fun pendingCount(): Int {
|
||||
val current = state.load()
|
||||
return current.retryBatch?.events.orEmpty().size + current.buffer.size
|
||||
}
|
||||
|
||||
private suspend fun runAutomaticFlushes() {
|
||||
while (true) {
|
||||
flushSignals.receive()
|
||||
var retries = 0
|
||||
while (true) {
|
||||
val current = state.load()
|
||||
if (current.enabled != true || pendingCount() == 0) break
|
||||
if (current.retryBatch == null && pendingCount() < flushThreshold) {
|
||||
val signalled = withTimeoutOrNull(flushIntervalMillis) {
|
||||
flushSignals.receive()
|
||||
true
|
||||
} ?: false
|
||||
if (signalled) continue
|
||||
}
|
||||
|
||||
val result = flush()
|
||||
if (result.isSuccess || pendingCount() == 0) {
|
||||
retries = 0
|
||||
continue
|
||||
}
|
||||
val error = result.exceptionOrNull()
|
||||
if (error?.isPermanentDiagnosticsPayloadRejection() == true || retries >= automaticRetryCount) {
|
||||
break
|
||||
}
|
||||
retries += 1
|
||||
delay(retryBackoffMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearRetryBatch(batch: TelemetryBatch) {
|
||||
while (true) {
|
||||
val current = state.load()
|
||||
if (current.retryBatch != batch) return
|
||||
if (state.compareAndSet(current, current.copy(retryBatch = null))) return
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateState(update: (TelemetryState) -> TelemetryState) {
|
||||
while (true) {
|
||||
val current = state.load()
|
||||
if (state.compareAndSet(current, update(current))) return
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DefaultMaxBuffer = 100
|
||||
const val DefaultFlushThreshold = 20
|
||||
const val MaxEventsPerBatch = 50
|
||||
const val DefaultFlushIntervalMillis = 30_000L
|
||||
const val DefaultRetryBackoffMillis = 30_000L
|
||||
const val DefaultAutomaticRetryCount = 3
|
||||
}
|
||||
}
|
||||
|
||||
private data class TelemetryState(
|
||||
val enabled: Boolean? = null,
|
||||
val buffer: List<TelemetryEvent> = emptyList(),
|
||||
val retryBatch: TelemetryBatch? = null,
|
||||
)
|
||||
@@ -6,6 +6,7 @@ import com.vnidrop.app.PlatformEnvironment
|
||||
import com.vnidrop.app.AppDependencies
|
||||
import com.vnidrop.app.AppGraph
|
||||
import com.vnidrop.app.core.CoreGateway
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
|
||||
import com.vnidrop.app.logging.AppLogger
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||
@@ -36,12 +37,14 @@ class AppViewModel(
|
||||
private val repository: CoreGateway,
|
||||
preferencesRepository: PreferencesRepository,
|
||||
private val messages: UiMessageController,
|
||||
private val diagnostics: DiagnosticsCoordinator? = null,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(AppState())
|
||||
val state: StateFlow<AppState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
AppLogger.info("lifecycle", "app started", mapOf("platform" to environment.name))
|
||||
diagnostics?.record("app_open", mapOf("platform" to environment.name, "version" to environment.appVersion))
|
||||
viewModelScope.launch {
|
||||
repository.initialize(environment.defaultCoreDataDir).onFailure(messages::error)
|
||||
}
|
||||
@@ -54,5 +57,6 @@ class AppViewModel(
|
||||
|
||||
fun selectDestination(destination: AppDestination) {
|
||||
_state.update { it.copy(destination = destination) }
|
||||
diagnostics?.record("nav_select", mapOf("destination" to destination.name))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -59,7 +59,6 @@ import com.vnidrop.app.ui.state.progressForReceiver
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import org.jetbrains.compose.resources.decodeToImageBitmap
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import qrcode.QRCode
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import vnidrop.shared.generated.resources.*
|
||||
@@ -247,7 +246,7 @@ internal fun TransferSharePanel(
|
||||
val renderedBitmap by produceState(qrBitmap, ticket, qrBitmap) {
|
||||
if (value == null) {
|
||||
value = withContext(Dispatchers.Default) {
|
||||
runCatching { QRCode.ofSquares().withSize(8).build(ticket).renderToBytes().decodeToImageBitmap() }.getOrNull()
|
||||
runCatching { buildTransferQrCode(ticket).renderToBytes().decodeToImageBitmap() }.getOrNull()
|
||||
}
|
||||
value?.let { onQrRendered(ticket, it) }
|
||||
}
|
||||
@@ -259,7 +258,12 @@ internal fun TransferSharePanel(
|
||||
color = Color.White,
|
||||
) {
|
||||
if (renderedQr != null) {
|
||||
Image(renderedQr, null, Modifier.padding(14.dp).fillMaxSize().clip(RoundedCornerShape(8.dp)))
|
||||
Image(
|
||||
bitmap = renderedQr,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(14.dp).fillMaxSize(),
|
||||
filterQuality = FilterQuality.None,
|
||||
)
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.vnidrop.app.feature.send
|
||||
|
||||
import qrcode.QRCode
|
||||
import qrcode.raw.ErrorCorrectionLevel
|
||||
|
||||
private const val QrCellSize = 8
|
||||
private const val QrQuietZoneModules = 4
|
||||
// qrcode-kotlin 4.5.0 falls back to version 40 above version 20's byte capacity.
|
||||
private val QrLowErrorCorrectionByteCapacities = intArrayOf(
|
||||
17, 32, 53, 78, 106, 134, 154, 192, 230, 271,
|
||||
321, 367, 425, 458, 520, 586, 644, 718, 792, 858,
|
||||
929, 1003, 1091, 1171, 1273, 1367, 1465, 1528, 1628, 1732,
|
||||
1840, 1952, 2068, 2188, 2303, 2431, 2563, 2699, 2809, 2953,
|
||||
)
|
||||
|
||||
internal fun buildTransferQrCode(ticket: String): QRCode =
|
||||
QRCode.ofSquares()
|
||||
.withSize(QrCellSize)
|
||||
.withInnerSpacing(0)
|
||||
.withMargin(QrQuietZoneModules * QrCellSize)
|
||||
.withErrorCorrectionLevel(ErrorCorrectionLevel.LOW)
|
||||
.withInformationDensity(transferQrInformationDensity(ticket))
|
||||
.build(ticket)
|
||||
|
||||
internal fun transferQrInformationDensity(ticket: String): Int {
|
||||
val byteCount = ticket.encodeToByteArray().size
|
||||
val index = QrLowErrorCorrectionByteCapacities.indexOfFirst { byteCount <= it }
|
||||
return if (index >= 0) index + 1 else throw IllegalArgumentException("The invitation is too large for a QR code")
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.about_bug_report
|
||||
@@ -12,13 +13,21 @@ import vnidrop.shared.generated.resources.about_title
|
||||
import vnidrop.shared.generated.resources.battery_level_title
|
||||
import vnidrop.shared.generated.resources.device_model_title
|
||||
import vnidrop.shared.generated.resources.device_name_title
|
||||
import vnidrop.shared.generated.resources.diagnostics_description
|
||||
import vnidrop.shared.generated.resources.diagnostics_title
|
||||
import vnidrop.shared.generated.resources.network_title
|
||||
import vnidrop.shared.generated.resources.os_version_title
|
||||
import vnidrop.shared.generated.resources.value_unavailable
|
||||
import vnidrop.shared.generated.resources.version_title
|
||||
|
||||
@Composable
|
||||
internal fun AboutSettings(state: SettingsState, onBack: () -> Unit, showBack: Boolean) {
|
||||
internal fun AboutSettings(
|
||||
state: SettingsState,
|
||||
onDiagnosticsChanged: (Boolean) -> Unit,
|
||||
onReportBug: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
showBack: Boolean,
|
||||
) {
|
||||
val unavailable = stringResource(Res.string.value_unavailable)
|
||||
val info = state.deviceInfo
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
@@ -29,11 +38,23 @@ internal fun AboutSettings(state: SettingsState, onBack: () -> Unit, showBack: B
|
||||
title = stringResource(Res.string.about_privacy),
|
||||
iconTone = SettingsIconTone.Neutral,
|
||||
)
|
||||
if (DiagnosticsBuildConfig.INCLUDED) {
|
||||
SettingsDivider()
|
||||
SettingsToggleRow(
|
||||
icon = SettingsIcons.Info,
|
||||
title = stringResource(Res.string.diagnostics_title),
|
||||
description = stringResource(Res.string.diagnostics_description),
|
||||
checked = state.diagnosticsEnabled,
|
||||
enabled = true,
|
||||
onCheckedChange = onDiagnosticsChanged,
|
||||
)
|
||||
}
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
icon = SettingsIcons.Bug,
|
||||
title = stringResource(Res.string.about_bug_report),
|
||||
iconTone = SettingsIconTone.Neutral,
|
||||
onClick = onReportBug,
|
||||
)
|
||||
}
|
||||
SettingsGroup {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.vnidrop.app.feature.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.ui.components.Field
|
||||
import com.vnidrop.app.ui.components.PrimaryButton
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.about_bug_report
|
||||
import vnidrop.shared.generated.resources.bug_report_contact_label
|
||||
import vnidrop.shared.generated.resources.bug_report_description
|
||||
import vnidrop.shared.generated.resources.bug_report_device_section
|
||||
import vnidrop.shared.generated.resources.bug_report_expected_label
|
||||
import vnidrop.shared.generated.resources.bug_report_include_logs
|
||||
import vnidrop.shared.generated.resources.bug_report_include_logs_description
|
||||
import vnidrop.shared.generated.resources.bug_report_logs_size
|
||||
import vnidrop.shared.generated.resources.bug_report_steps_label
|
||||
import vnidrop.shared.generated.resources.bug_report_submit
|
||||
import vnidrop.shared.generated.resources.bug_report_submitting
|
||||
import vnidrop.shared.generated.resources.bug_report_what_label
|
||||
import vnidrop.shared.generated.resources.device_model_title
|
||||
import vnidrop.shared.generated.resources.device_name_title
|
||||
import vnidrop.shared.generated.resources.os_version_title
|
||||
import vnidrop.shared.generated.resources.value_unavailable
|
||||
import vnidrop.shared.generated.resources.version_title
|
||||
|
||||
@Composable
|
||||
internal fun BugReportSettings(
|
||||
state: SettingsState,
|
||||
onWhatChanged: (String) -> Unit,
|
||||
onExpectedChanged: (String) -> Unit,
|
||||
onStepsChanged: (String) -> Unit,
|
||||
onContactChanged: (String) -> Unit,
|
||||
onIncludeLogsChanged: (Boolean) -> Unit,
|
||||
onSubmit: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
showBack: Boolean,
|
||||
) {
|
||||
val unavailable = stringResource(Res.string.value_unavailable)
|
||||
val info = state.deviceInfo
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
SettingsTopBar(stringResource(Res.string.about_bug_report), onBack, showBack)
|
||||
Text(
|
||||
stringResource(Res.string.bug_report_description),
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Field(
|
||||
value = state.bugWhatHappened,
|
||||
onValueChange = onWhatChanged,
|
||||
label = stringResource(Res.string.bug_report_what_label),
|
||||
minLines = 3,
|
||||
enabled = !state.isSubmittingBugReport,
|
||||
)
|
||||
Field(
|
||||
value = state.bugExpected,
|
||||
onValueChange = onExpectedChanged,
|
||||
label = stringResource(Res.string.bug_report_expected_label),
|
||||
minLines = 2,
|
||||
enabled = !state.isSubmittingBugReport,
|
||||
)
|
||||
Field(
|
||||
value = state.bugSteps,
|
||||
onValueChange = onStepsChanged,
|
||||
label = stringResource(Res.string.bug_report_steps_label),
|
||||
minLines = 2,
|
||||
enabled = !state.isSubmittingBugReport,
|
||||
)
|
||||
Field(
|
||||
value = state.bugContact,
|
||||
onValueChange = onContactChanged,
|
||||
label = stringResource(Res.string.bug_report_contact_label),
|
||||
enabled = !state.isSubmittingBugReport,
|
||||
)
|
||||
SettingsGroup {
|
||||
SettingsToggleRow(
|
||||
icon = SettingsIcons.Document,
|
||||
title = stringResource(Res.string.bug_report_include_logs),
|
||||
description = stringResource(Res.string.bug_report_include_logs_description),
|
||||
checked = state.bugIncludeLogs,
|
||||
enabled = !state.isSubmittingBugReport,
|
||||
onCheckedChange = onIncludeLogsChanged,
|
||||
)
|
||||
if (state.bugIncludeLogs && state.bugLogPreviewBytes > 0) {
|
||||
SettingsDivider()
|
||||
InfoItem(
|
||||
stringResource(Res.string.bug_report_logs_size),
|
||||
formatByteSize(state.bugLogPreviewBytes),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(Res.string.bug_report_device_section),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
SettingsGroup {
|
||||
InfoItem(stringResource(Res.string.version_title), state.appVersion)
|
||||
SettingsDivider(startPadding = 16.dp)
|
||||
InfoItem(stringResource(Res.string.device_name_title), info?.deviceName.orUnavailable(unavailable))
|
||||
SettingsDivider(startPadding = 16.dp)
|
||||
InfoItem(stringResource(Res.string.device_model_title), info?.deviceModel.orUnavailable(unavailable))
|
||||
SettingsDivider(startPadding = 16.dp)
|
||||
InfoItem(stringResource(Res.string.os_version_title), info?.operatingSystem ?: unavailable)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
PrimaryButton(
|
||||
text = if (state.isSubmittingBugReport) {
|
||||
stringResource(Res.string.bug_report_submitting)
|
||||
} else {
|
||||
stringResource(Res.string.bug_report_submit)
|
||||
},
|
||||
onClick = onSubmit,
|
||||
enabled = !state.isSubmittingBugReport,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String?.orUnavailable(fallback: String): String =
|
||||
this?.takeIf(String::isNotBlank) ?: fallback
|
||||
|
||||
private fun formatByteSize(bytes: Int): String = when {
|
||||
bytes < 1024 -> "$bytes B"
|
||||
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
||||
else -> "${bytes / (1024 * 1024)} MB"
|
||||
}
|
||||
@@ -28,5 +28,12 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) {
|
||||
onResetFolder = viewModel::resetReceiveFolder,
|
||||
onNotificationsChanged = viewModel::setNotificationsEnabled,
|
||||
onOpenNotificationSettings = viewModel::openNotificationSettings,
|
||||
onDiagnosticsChanged = viewModel::setDiagnosticsEnabled,
|
||||
onBugWhatChanged = viewModel::setBugWhatHappened,
|
||||
onBugExpectedChanged = viewModel::setBugExpected,
|
||||
onBugStepsChanged = viewModel::setBugSteps,
|
||||
onBugContactChanged = viewModel::setBugContact,
|
||||
onBugIncludeLogsChanged = viewModel::setBugIncludeLogs,
|
||||
onSubmitBugReport = viewModel::submitBugReport,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ fun SettingsScreen(
|
||||
onResetFolder: () -> Unit,
|
||||
onNotificationsChanged: (Boolean) -> Unit,
|
||||
onOpenNotificationSettings: () -> Unit,
|
||||
onDiagnosticsChanged: (Boolean) -> Unit,
|
||||
onBugWhatChanged: (String) -> Unit,
|
||||
onBugExpectedChanged: (String) -> Unit,
|
||||
onBugStepsChanged: (String) -> Unit,
|
||||
onBugContactChanged: (String) -> Unit,
|
||||
onBugIncludeLogsChanged: (Boolean) -> Unit,
|
||||
onSubmitBugReport: () -> Unit,
|
||||
) {
|
||||
if (windowClass == WindowClass.Desktop) {
|
||||
Row(
|
||||
@@ -37,12 +44,20 @@ fun SettingsScreen(
|
||||
section = state.selectedSection.takeUnless { it == SettingsSection.Overview } ?: SettingsSection.Preferences,
|
||||
onBack = {},
|
||||
showBack = false,
|
||||
onSectionSelected = onSectionSelected,
|
||||
onUsernameChanged = onUsernameChanged,
|
||||
onThemeModeChanged = onThemeModeChanged,
|
||||
onChooseFolder = onChooseFolder,
|
||||
onResetFolder = onResetFolder,
|
||||
onNotificationsChanged = onNotificationsChanged,
|
||||
onOpenNotificationSettings = onOpenNotificationSettings,
|
||||
onDiagnosticsChanged = onDiagnosticsChanged,
|
||||
onBugWhatChanged = onBugWhatChanged,
|
||||
onBugExpectedChanged = onBugExpectedChanged,
|
||||
onBugStepsChanged = onBugStepsChanged,
|
||||
onBugContactChanged = onBugContactChanged,
|
||||
onBugIncludeLogsChanged = onBugIncludeLogsChanged,
|
||||
onSubmitBugReport = onSubmitBugReport,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -52,14 +67,30 @@ fun SettingsScreen(
|
||||
else -> SettingsSectionContent(
|
||||
state = state,
|
||||
section = state.selectedSection,
|
||||
onBack = { onSectionSelected(SettingsSection.Overview) },
|
||||
onBack = {
|
||||
onSectionSelected(
|
||||
if (state.selectedSection == SettingsSection.BugReport) {
|
||||
SettingsSection.About
|
||||
} else {
|
||||
SettingsSection.Overview
|
||||
},
|
||||
)
|
||||
},
|
||||
showBack = true,
|
||||
onSectionSelected = onSectionSelected,
|
||||
onUsernameChanged = onUsernameChanged,
|
||||
onThemeModeChanged = onThemeModeChanged,
|
||||
onChooseFolder = onChooseFolder,
|
||||
onResetFolder = onResetFolder,
|
||||
onNotificationsChanged = onNotificationsChanged,
|
||||
onOpenNotificationSettings = onOpenNotificationSettings,
|
||||
onDiagnosticsChanged = onDiagnosticsChanged,
|
||||
onBugWhatChanged = onBugWhatChanged,
|
||||
onBugExpectedChanged = onBugExpectedChanged,
|
||||
onBugStepsChanged = onBugStepsChanged,
|
||||
onBugContactChanged = onBugContactChanged,
|
||||
onBugIncludeLogsChanged = onBugIncludeLogsChanged,
|
||||
onSubmitBugReport = onSubmitBugReport,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -71,18 +102,43 @@ private fun SettingsSectionContent(
|
||||
section: SettingsSection,
|
||||
onBack: () -> Unit,
|
||||
showBack: Boolean,
|
||||
onSectionSelected: (SettingsSection) -> Unit,
|
||||
onUsernameChanged: (String) -> Unit,
|
||||
onThemeModeChanged: (ThemeMode) -> Unit,
|
||||
onChooseFolder: () -> Unit,
|
||||
onResetFolder: () -> Unit,
|
||||
onNotificationsChanged: (Boolean) -> Unit,
|
||||
onOpenNotificationSettings: () -> Unit,
|
||||
onDiagnosticsChanged: (Boolean) -> Unit,
|
||||
onBugWhatChanged: (String) -> Unit,
|
||||
onBugExpectedChanged: (String) -> Unit,
|
||||
onBugStepsChanged: (String) -> Unit,
|
||||
onBugContactChanged: (String) -> Unit,
|
||||
onBugIncludeLogsChanged: (Boolean) -> Unit,
|
||||
onSubmitBugReport: () -> Unit,
|
||||
) {
|
||||
when (section) {
|
||||
SettingsSection.Overview -> Unit
|
||||
SettingsSection.Preferences -> PreferencesSettings(state, onUsernameChanged, onChooseFolder, onResetFolder, onBack, showBack)
|
||||
SettingsSection.Appearance -> AppearanceSettings(state.themeMode, onThemeModeChanged, onBack, showBack)
|
||||
SettingsSection.Notifications -> NotificationSettings(state, onNotificationsChanged, onOpenNotificationSettings, onBack, showBack)
|
||||
SettingsSection.About -> AboutSettings(state, onBack, showBack)
|
||||
SettingsSection.About -> AboutSettings(
|
||||
state = state,
|
||||
onDiagnosticsChanged = onDiagnosticsChanged,
|
||||
onReportBug = { onSectionSelected(SettingsSection.BugReport) },
|
||||
onBack = onBack,
|
||||
showBack = showBack,
|
||||
)
|
||||
SettingsSection.BugReport -> BugReportSettings(
|
||||
state = state,
|
||||
onWhatChanged = onBugWhatChanged,
|
||||
onExpectedChanged = onBugExpectedChanged,
|
||||
onStepsChanged = onBugStepsChanged,
|
||||
onContactChanged = onBugContactChanged,
|
||||
onIncludeLogsChanged = onBugIncludeLogsChanged,
|
||||
onSubmit = onSubmitBugReport,
|
||||
onBack = onBack,
|
||||
showBack = showBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ import com.vnidrop.app.PlatformEnvironment
|
||||
import com.vnidrop.app.core.FileSystemService
|
||||
import com.vnidrop.app.core.FolderAccessStatus
|
||||
import com.vnidrop.app.core.ReceiveFolder
|
||||
import com.vnidrop.app.diagnostics.BugReportDraft
|
||||
import com.vnidrop.app.diagnostics.BugReportService
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsCoordinator
|
||||
import com.vnidrop.app.notifications.LocalNotificationService
|
||||
import com.vnidrop.app.notifications.NotificationPermission
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
@@ -27,7 +31,13 @@ import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.bug_report_missing_expected
|
||||
import vnidrop.shared.generated.resources.bug_report_missing_what
|
||||
import vnidrop.shared.generated.resources.bug_report_submit_failed
|
||||
import vnidrop.shared.generated.resources.bug_report_submitted
|
||||
import vnidrop.shared.generated.resources.button_open_settings
|
||||
import vnidrop.shared.generated.resources.diagnostics_disabled_message
|
||||
import vnidrop.shared.generated.resources.diagnostics_enabled_message
|
||||
import vnidrop.shared.generated.resources.notifications_enabled_message
|
||||
import vnidrop.shared.generated.resources.notifications_permission_denied
|
||||
import vnidrop.shared.generated.resources.notifications_settings_open_failed
|
||||
@@ -39,6 +49,7 @@ enum class SettingsSection {
|
||||
Appearance,
|
||||
Notifications,
|
||||
About,
|
||||
BugReport,
|
||||
}
|
||||
|
||||
data class SettingsState(
|
||||
@@ -50,9 +61,17 @@ data class SettingsState(
|
||||
val themeMode: ThemeMode = ThemeMode.System,
|
||||
val notificationsEnabled: Boolean = false,
|
||||
val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined,
|
||||
val diagnosticsEnabled: Boolean = false,
|
||||
val deviceInfo: DeviceInfo? = null,
|
||||
val appVersion: String = "",
|
||||
val isLoadingDeviceInfo: Boolean = false,
|
||||
val bugWhatHappened: String = "",
|
||||
val bugExpected: String = "",
|
||||
val bugSteps: String = "",
|
||||
val bugContact: String = "",
|
||||
val bugIncludeLogs: Boolean = true,
|
||||
val isSubmittingBugReport: Boolean = false,
|
||||
val bugLogPreviewBytes: Int = 0,
|
||||
)
|
||||
|
||||
sealed interface SettingsEffect {
|
||||
@@ -66,6 +85,8 @@ class SettingsViewModel(
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
private val notifications: LocalNotificationService,
|
||||
private val messages: UiMessageController,
|
||||
private val bugReports: BugReportService,
|
||||
private val diagnostics: DiagnosticsCoordinator? = null,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(SettingsState(appVersion = environment.appVersion))
|
||||
val state: StateFlow<SettingsState> = _state.asStateFlow()
|
||||
@@ -88,6 +109,7 @@ class SettingsViewModel(
|
||||
receiveFolder = preferences.receiveFolder,
|
||||
themeMode = preferences.themeMode,
|
||||
notificationsEnabled = preferences.notificationsEnabled,
|
||||
diagnosticsEnabled = preferences.diagnosticsEnabled,
|
||||
)
|
||||
}
|
||||
if (preferences.receiveFolder != previousFolder) {
|
||||
@@ -101,7 +123,13 @@ class SettingsViewModel(
|
||||
|
||||
fun selectSection(section: SettingsSection) {
|
||||
_state.update { it.copy(selectedSection = section) }
|
||||
if (section == SettingsSection.About) loadDeviceInfo()
|
||||
when (section) {
|
||||
SettingsSection.About, SettingsSection.BugReport -> {
|
||||
loadDeviceInfo()
|
||||
if (section == SettingsSection.BugReport) refreshBugLogPreview()
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun setUsername(value: String) {
|
||||
@@ -164,6 +192,90 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun setDiagnosticsEnabled(enabled: Boolean) {
|
||||
if (!DiagnosticsBuildConfig.INCLUDED) return
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setDiagnosticsEnabled(enabled)
|
||||
diagnostics?.record(
|
||||
if (enabled) "diagnostics_enabled" else "diagnostics_disabled",
|
||||
)
|
||||
messages.show(
|
||||
UiMessage(
|
||||
UiText.Resource(
|
||||
if (enabled) Res.string.diagnostics_enabled_message
|
||||
else Res.string.diagnostics_disabled_message,
|
||||
),
|
||||
UiMessageTone.Success,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setBugWhatHappened(value: String) = _state.update { it.copy(bugWhatHappened = value) }
|
||||
fun setBugExpected(value: String) = _state.update { it.copy(bugExpected = value) }
|
||||
fun setBugSteps(value: String) = _state.update { it.copy(bugSteps = value) }
|
||||
fun setBugContact(value: String) = _state.update { it.copy(bugContact = value) }
|
||||
fun setBugIncludeLogs(value: Boolean) = _state.update { it.copy(bugIncludeLogs = value) }
|
||||
|
||||
fun submitBugReport() {
|
||||
if (_state.value.isSubmittingBugReport) return
|
||||
viewModelScope.launch {
|
||||
val snapshot = _state.value
|
||||
val what = snapshot.bugWhatHappened.trim()
|
||||
val expected = snapshot.bugExpected.trim()
|
||||
if (what.isEmpty()) {
|
||||
messages.show(UiMessage(UiText.Resource(Res.string.bug_report_missing_what), UiMessageTone.Warning))
|
||||
return@launch
|
||||
}
|
||||
if (expected.isEmpty()) {
|
||||
messages.show(UiMessage(UiText.Resource(Res.string.bug_report_missing_expected), UiMessageTone.Warning))
|
||||
return@launch
|
||||
}
|
||||
_state.update { it.copy(isSubmittingBugReport = true) }
|
||||
try {
|
||||
val result = bugReports.submit(
|
||||
BugReportDraft(
|
||||
whatHappened = what,
|
||||
expected = expected,
|
||||
steps = snapshot.bugSteps,
|
||||
contact = snapshot.bugContact,
|
||||
includeLogs = snapshot.bugIncludeLogs,
|
||||
),
|
||||
deviceInfo = snapshot.deviceInfo,
|
||||
)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
diagnostics?.record("bug_report_submitted")
|
||||
_state.update {
|
||||
it.copy(
|
||||
isSubmittingBugReport = false,
|
||||
bugWhatHappened = "",
|
||||
bugExpected = "",
|
||||
bugSteps = "",
|
||||
bugContact = "",
|
||||
bugIncludeLogs = true,
|
||||
)
|
||||
}
|
||||
messages.show(
|
||||
UiMessage(UiText.Resource(Res.string.bug_report_submitted), UiMessageTone.Success),
|
||||
)
|
||||
selectSection(SettingsSection.About)
|
||||
},
|
||||
onFailure = {
|
||||
_state.update { it.copy(isSubmittingBugReport = false) }
|
||||
messages.show(
|
||||
UiMessage(UiText.Resource(Res.string.bug_report_submit_failed), UiMessageTone.Error),
|
||||
)
|
||||
},
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
if (error is CancellationException) throw error
|
||||
_state.update { it.copy(isSubmittingBugReport = false) }
|
||||
messages.show(UiMessage(UiText.Resource(Res.string.bug_report_submit_failed), UiMessageTone.Error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openNotificationSettings() {
|
||||
viewModelScope.launch {
|
||||
enableNotificationsAfterSettings = true
|
||||
@@ -212,6 +324,13 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshBugLogPreview() {
|
||||
viewModelScope.launch {
|
||||
val bytes = runCatching { bugReports.previewLogBytes() }.getOrDefault(0)
|
||||
_state.update { it.copy(bugLogPreviewBytes = bytes) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun validateFolder(folder: ReceiveFolder) {
|
||||
_state.update { it.copy(isValidatingFolder = true) }
|
||||
val status = fileSystemService.validateReceiveFolder(folder)
|
||||
|
||||
@@ -31,6 +31,8 @@ interface PlatformLogStore {
|
||||
val logDirectory: String
|
||||
fun append(line: String)
|
||||
fun listLogFiles(): List<LogFileInfo>
|
||||
/** Newest log content, up to [maxBytes], from the active file then rotated tails if needed. */
|
||||
fun readLatest(maxBytes: Long): String
|
||||
}
|
||||
|
||||
expect fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore
|
||||
@@ -72,6 +74,9 @@ object AppLogger {
|
||||
fun listLogFiles(): List<LogFileInfo> =
|
||||
store?.listLogFiles().orEmpty()
|
||||
|
||||
fun readLatestLogs(maxBytes: Long = DefaultBugReportLogBytes): String =
|
||||
store?.readLatest(maxBytes).orEmpty()
|
||||
|
||||
private fun write(level: AppLogLevel, scope: String, message: String, fields: Map<String, String>) {
|
||||
val line = buildString {
|
||||
append(platformNowMillis())
|
||||
@@ -89,6 +94,8 @@ object AppLogger {
|
||||
}
|
||||
store?.append(line)
|
||||
}
|
||||
|
||||
const val DefaultBugReportLogBytes: Long = 256 * 1024
|
||||
}
|
||||
|
||||
private fun String.sanitizeLogValue(): String =
|
||||
|
||||
@@ -10,8 +10,10 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import com.vnidrop.app.core.ReceiveFolder
|
||||
import com.vnidrop.app.core.ReceiveFolderKind
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import com.vnidrop.app.util.randomUuidString
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import okio.Path.Companion.toPath
|
||||
|
||||
@@ -20,6 +22,10 @@ data class AppPreferences(
|
||||
val receiveFolder: ReceiveFolder,
|
||||
val themeMode: ThemeMode,
|
||||
val notificationsEnabled: Boolean,
|
||||
/** Master opt-in for automatic telemetry + crash upload. Bug reports remain available always. */
|
||||
val diagnosticsEnabled: Boolean = false,
|
||||
/** Stable anonymous install id; never an account or advertising id. */
|
||||
val diagnosticsInstallId: String = "",
|
||||
)
|
||||
|
||||
class AppPreferencesDefaults(
|
||||
@@ -27,6 +33,7 @@ class AppPreferencesDefaults(
|
||||
val receiveFolder: ReceiveFolder,
|
||||
val themeMode: ThemeMode,
|
||||
val notificationsEnabled: Boolean = false,
|
||||
val diagnosticsEnabled: Boolean = false,
|
||||
)
|
||||
|
||||
interface PreferencesRepository {
|
||||
@@ -36,6 +43,9 @@ interface PreferencesRepository {
|
||||
suspend fun resetReceiveFolder()
|
||||
suspend fun setThemeMode(mode: ThemeMode)
|
||||
suspend fun setNotificationsEnabled(enabled: Boolean)
|
||||
suspend fun setDiagnosticsEnabled(enabled: Boolean)
|
||||
/** Ensures a durable install id exists and returns it. */
|
||||
suspend fun ensureDiagnosticsInstallId(): String
|
||||
}
|
||||
|
||||
class AppPreferencesRepository(
|
||||
@@ -50,6 +60,8 @@ class AppPreferencesRepository(
|
||||
receiveFolder = resolveReceiveFolder(prefs, defaults.receiveFolder),
|
||||
themeMode = prefs[PreferenceKeys.ThemeMode]?.let { themeModeOrNull(it) } ?: defaults.themeMode,
|
||||
notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled,
|
||||
diagnosticsEnabled = prefs[PreferenceKeys.DiagnosticsEnabled] ?: defaults.diagnosticsEnabled,
|
||||
diagnosticsInstallId = prefs[PreferenceKeys.DiagnosticsInstallId].orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -82,6 +94,24 @@ class AppPreferencesRepository(
|
||||
prefs[PreferenceKeys.NotificationsEnabled] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setDiagnosticsEnabled(enabled: Boolean) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[PreferenceKeys.DiagnosticsEnabled] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun ensureDiagnosticsInstallId(): String {
|
||||
val existing = preferences.first().diagnosticsInstallId
|
||||
if (existing.isNotBlank()) return existing
|
||||
val created = randomUuidString()
|
||||
dataStore.edit { prefs ->
|
||||
if (prefs[PreferenceKeys.DiagnosticsInstallId].isNullOrBlank()) {
|
||||
prefs[PreferenceKeys.DiagnosticsInstallId] = created
|
||||
}
|
||||
}
|
||||
return preferences.first().diagnosticsInstallId.ifBlank { created }
|
||||
}
|
||||
}
|
||||
|
||||
fun createAppPreferencesDataStore(appDataDir: String): DataStore<Preferences> =
|
||||
@@ -96,6 +126,8 @@ private object PreferenceKeys {
|
||||
val ReceiveFolderDisplayName = stringPreferencesKey("receive_folder_display_name")
|
||||
val ThemeMode = stringPreferencesKey("theme_mode")
|
||||
val NotificationsEnabled = booleanPreferencesKey("notifications_enabled")
|
||||
val DiagnosticsEnabled = booleanPreferencesKey("diagnostics_enabled")
|
||||
val DiagnosticsInstallId = stringPreferencesKey("diagnostics_install_id")
|
||||
}
|
||||
|
||||
private fun resolveReceiveFolder(prefs: Preferences, defaults: ReceiveFolder): ReceiveFolder {
|
||||
|
||||
20
shared/src/commonMain/kotlin/com/vnidrop/app/util/Uuid.kt
Normal file
20
shared/src/commonMain/kotlin/com/vnidrop/app/util/Uuid.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.vnidrop.app.util
|
||||
|
||||
import kotlin.random.Random
|
||||
|
||||
/** RFC 4122 version-4 UUID string without depending on java.util.UUID in commonMain. */
|
||||
fun randomUuidString(random: Random = Random.Default): String {
|
||||
val bytes = ByteArray(16)
|
||||
random.nextBytes(bytes)
|
||||
bytes[6] = ((bytes[6].toInt() and 0x0f) or 0x40).toByte()
|
||||
bytes[8] = ((bytes[8].toInt() and 0x3f) or 0x80).toByte()
|
||||
return buildString(36) {
|
||||
bytes.forEachIndexed { index, byte ->
|
||||
if (index == 4 || index == 6 || index == 8 || index == 10) append('-')
|
||||
append(HEX[(byte.toInt() ushr 4) and 0x0f])
|
||||
append(HEX[byte.toInt() and 0x0f])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val HEX = "0123456789abcdef".toCharArray()
|
||||
@@ -0,0 +1,984 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import com.vnidrop.app.DeviceInfo
|
||||
import com.vnidrop.app.core.ReceiveFolder
|
||||
import com.vnidrop.app.core.ReceiveFolderKind
|
||||
import com.vnidrop.app.preferences.AppPreferences
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.support.FakePreferencesRepository
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DiagnosticsTest {
|
||||
@Test
|
||||
fun diagnosticsJsonEscapesAndShapesPayloads() {
|
||||
val eventsJson = DiagnosticsJson.eventsBody(
|
||||
batchId = "batch-1",
|
||||
installId = "inst-1",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
events = listOf(
|
||||
TelemetryEvent("app_open", 10L, mapOf("a" to "quote\"here")),
|
||||
),
|
||||
)
|
||||
assertTrue(eventsJson.contains("\"batchId\":\"batch-1\""))
|
||||
assertTrue(eventsJson.contains("\"installId\":\"inst-1\""))
|
||||
assertTrue(eventsJson.contains("\"name\":\"app_open\""))
|
||||
assertTrue(eventsJson.contains("quote\\\"here"))
|
||||
|
||||
val crashJson = DiagnosticsJson.crashBody(
|
||||
CrashReport(
|
||||
id = "c1",
|
||||
timestampMillis = 1L,
|
||||
installId = "inst",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
exceptionType = "E",
|
||||
exceptionMessage = "line\nbreak",
|
||||
stackTrace = "stack",
|
||||
breadcrumbs = emptyList(),
|
||||
diagnosticsEnabledAtCapture = true,
|
||||
),
|
||||
)
|
||||
assertTrue(crashJson.contains("line\\nbreak"))
|
||||
assertTrue(crashJson.contains("\"diagnosticsEnabledAtCapture\":true"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun diagnosticsJsonKeepsEscapedBugPayloadWithinWorkerLimit() {
|
||||
val report = BugReport(
|
||||
id = "b",
|
||||
timestampMillis = 1L,
|
||||
installId = "i",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
whatHappened = "w",
|
||||
expected = "e",
|
||||
steps = "",
|
||||
contact = "",
|
||||
includeLogs = true,
|
||||
logs = "\n".repeat(BugReportService.MaxLogBytes),
|
||||
device = DeviceSnapshot(null, null, "OS", null, null),
|
||||
breadcrumbs = emptyList(),
|
||||
)
|
||||
|
||||
val body = DiagnosticsJson.bugBody(report)
|
||||
|
||||
assertTrue(body.encodeToByteArray().size <= DiagnosticsJson.MaxRequestBytes)
|
||||
assertTrue(body.contains("\"includeLogs\":true"))
|
||||
assertTrue(body.endsWith("}"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun httpTransportPostsExpectedPaths() = runTest {
|
||||
val calls = mutableListOf<Pair<String, String>>()
|
||||
val acknowledgementIds = ArrayDeque(listOf("batch-1", "c", "b"))
|
||||
val transport = HttpDiagnosticsTransport(
|
||||
baseUrl = "https://diag.example",
|
||||
ingestKey = "secret",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
installIdProvider = { "install-x" },
|
||||
post = { url, headers, body ->
|
||||
assertEquals("secret", headers["X-VniDrop-Key"])
|
||||
calls += url to body
|
||||
PlatformHttpResponse(
|
||||
202,
|
||||
"""{"ok":true,"id":"${acknowledgementIds.removeFirst()}","stored":1}""",
|
||||
)
|
||||
},
|
||||
)
|
||||
val eventResult = transport.sendEvents(
|
||||
TelemetryBatch("batch-1", listOf(TelemetryEvent("nav", 1L))),
|
||||
)
|
||||
assertTrue(eventResult.isSuccess)
|
||||
assertEquals("https://diag.example/v1/events", calls[0].first)
|
||||
assertTrue(calls[0].second.contains("install-x"))
|
||||
|
||||
val crashResult = transport.sendCrash(
|
||||
CrashReport(
|
||||
id = "c",
|
||||
timestampMillis = 1L,
|
||||
installId = "i",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
exceptionType = "E",
|
||||
exceptionMessage = "m",
|
||||
stackTrace = "s",
|
||||
breadcrumbs = emptyList(),
|
||||
diagnosticsEnabledAtCapture = true,
|
||||
),
|
||||
)
|
||||
assertTrue(crashResult.isSuccess)
|
||||
assertEquals("https://diag.example/v1/crashes", calls[1].first)
|
||||
|
||||
val bugResult = transport.sendBugReport(
|
||||
BugReport(
|
||||
id = "b",
|
||||
timestampMillis = 1L,
|
||||
installId = "i",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
whatHappened = "w",
|
||||
expected = "e",
|
||||
steps = "",
|
||||
contact = "",
|
||||
includeLogs = false,
|
||||
logs = "",
|
||||
device = DeviceSnapshot(null, null, "OS", null, null),
|
||||
breadcrumbs = emptyList(),
|
||||
),
|
||||
)
|
||||
assertTrue(bugResult.isSuccess)
|
||||
assertEquals("https://diag.example/v1/bugs", calls[2].first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun httpTransportParsesEscapedAcknowledgementWithNestedUnknownFields() = runTest {
|
||||
val transport = HttpDiagnosticsTransport(
|
||||
baseUrl = "https://diag.example",
|
||||
ingestKey = "secret",
|
||||
post = { _, _, _ ->
|
||||
PlatformHttpResponse(
|
||||
202,
|
||||
"""{"\u006f\u006b":true,"id":"batch-\u0031","metadata":{"stored":1,"flags":[true,null]}}""",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assertTrue(transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L)))).isSuccess)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun httpTransportFailsOnHttpError() = runTest {
|
||||
val transport = HttpDiagnosticsTransport(
|
||||
baseUrl = "https://diag.example",
|
||||
ingestKey = "secret",
|
||||
post = { _, _, _ -> PlatformHttpResponse(401, """{"error":"unauthorized"}""") },
|
||||
)
|
||||
assertTrue(
|
||||
transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L)))).isFailure,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun httpTransportPropagatesCancellation() = runTest {
|
||||
val transport = HttpDiagnosticsTransport(
|
||||
baseUrl = "https://diag.example",
|
||||
ingestKey = "secret",
|
||||
post = { _, _, _ -> throw CancellationException("cancelled") },
|
||||
)
|
||||
|
||||
assertFailsWith<CancellationException> {
|
||||
transport.sendEvents(TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1))))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun httpTransportRejectsMissingOrNegativeAcknowledgement() = runTest {
|
||||
val responses = ArrayDeque(
|
||||
listOf(
|
||||
PlatformHttpResponse(202, ""),
|
||||
PlatformHttpResponse(202, """{"ok":false}"""),
|
||||
PlatformHttpResponse(202, """{,"ok":true}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"different"}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"batch-4","id":"batch-4"}"""),
|
||||
),
|
||||
)
|
||||
val transport = HttpDiagnosticsTransport(
|
||||
baseUrl = "https://diag.example",
|
||||
ingestKey = "secret",
|
||||
post = { _, _, _ -> responses.removeFirst() },
|
||||
)
|
||||
|
||||
repeat(5) { index ->
|
||||
val result = transport.sendEvents(
|
||||
TelemetryBatch("batch-$index", listOf(TelemetryEvent("x", 1L))),
|
||||
)
|
||||
assertIs<DiagnosticsProtocolException>(result.exceptionOrNull())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun httpTransportRejectsAmbiguousOrMalformedJsonAcknowledgement() = runTest {
|
||||
val responses = ArrayDeque(
|
||||
listOf(
|
||||
PlatformHttpResponse(202, """{"ok":true,"\u006f\u006b":true,"id":"batch-0"}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":true}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"batch-2","stored":01}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"batch-3",}"""),
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"batch-4"} trailing"""),
|
||||
),
|
||||
)
|
||||
val transport = HttpDiagnosticsTransport(
|
||||
baseUrl = "https://diag.example",
|
||||
ingestKey = "secret",
|
||||
post = { _, _, _ -> responses.removeFirst() },
|
||||
)
|
||||
|
||||
repeat(5) { index ->
|
||||
val result = transport.sendEvents(
|
||||
TelemetryBatch("batch-$index", listOf(TelemetryEvent("x", 1L))),
|
||||
)
|
||||
assertIs<DiagnosticsProtocolException>(result.exceptionOrNull())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun httpTransportRequiresHttpsExceptForLoopbackDevelopment() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
HttpDiagnosticsTransport("http://diag.example", "secret")
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
HttpDiagnosticsTransport("http://[::1].example", "secret")
|
||||
}
|
||||
HttpDiagnosticsTransport("http://localhost:8787", "secret")
|
||||
HttpDiagnosticsTransport("http://127.0.0.1:8787", "secret")
|
||||
HttpDiagnosticsTransport("http://[::1]:8787", "secret")
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
HttpDiagnosticsTransport("https://diag.example", "")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createTransportIsNoOpForExplicitEmptyConfiguration() {
|
||||
val transport = buildDiagnosticsTransport(
|
||||
endpoint = "",
|
||||
ingestKey = "",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
installIdProvider = { "id" },
|
||||
)
|
||||
assertIs<NoOpDiagnosticsTransport>(transport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noOpTransportReportsUnavailableDelivery() = runTest {
|
||||
val result = NoOpDiagnosticsTransport().sendEvents(
|
||||
TelemetryBatch("batch-1", listOf(TelemetryEvent("x", 1L))),
|
||||
)
|
||||
assertIs<DiagnosticsUnavailableException>(result.exceptionOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun logRedactorScrubsTicketsPathsAndEndpointIds() {
|
||||
val input = """
|
||||
ticket=abcdefghijklmnopqrstuvwxyz012345
|
||||
endpoint_id=peerABCDEFGHIJKLMNOP
|
||||
path=/Users/me/secret/file.bin
|
||||
uri=content://com.android.providers/downloads/1
|
||||
ok=value
|
||||
""".trimIndent()
|
||||
val redacted = LogRedactor.redact(input)
|
||||
assertFalse(redacted.contains("abcdefghijklmnopqrstuvwxyz012345"))
|
||||
assertFalse(redacted.contains("peerABCDEFGHIJKLMNOP"))
|
||||
assertFalse(redacted.contains("/users/me/secret/file.bin", ignoreCase = true))
|
||||
assertFalse(redacted.contains("content://"))
|
||||
assertTrue(redacted.contains("ok=value"))
|
||||
assertTrue(redacted.contains("[redacted-ticket]"))
|
||||
assertTrue(redacted.contains("[redacted-endpoint]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun breadcrumbBufferKeepsOnlyLatestEntries() {
|
||||
val buffer = BreadcrumbBuffer(capacity = 3)
|
||||
buffer.add("a")
|
||||
buffer.add("b")
|
||||
buffer.add("c")
|
||||
buffer.add("d")
|
||||
assertEquals(listOf("b", "c", "d"), buffer.snapshot().map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryIgnoresEventsWhenDiagnosticsDisabled() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = false)
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val breadcrumbs = BreadcrumbBuffer()
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = breadcrumbs,
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 1,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
recorder.record("app_open")
|
||||
advanceUntilIdle()
|
||||
assertEquals(0, transport.events.size)
|
||||
assertEquals(1, breadcrumbs.snapshot().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryRetainsColdStartEventsUntilConsentLoads() = runTest {
|
||||
val backing = fakePrefs(diagnosticsEnabled = true)
|
||||
val preferenceGate = CompletableDeferred<Unit>()
|
||||
val delayedPreferences = object : PreferencesRepository by backing {
|
||||
override val preferences = flow {
|
||||
preferenceGate.await()
|
||||
emitAll(backing.preferences)
|
||||
}
|
||||
}
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = delayedPreferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 20,
|
||||
)
|
||||
runCurrent()
|
||||
|
||||
recorder.record("app_open")
|
||||
assertEquals(1, recorder.pendingCount())
|
||||
preferenceGate.complete(Unit)
|
||||
runCurrent()
|
||||
assertTrue(recorder.flush().isSuccess)
|
||||
|
||||
assertEquals(listOf("app_open"), transport.events.single().map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryBuffersAndFlushesWhenEnabled() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 2,
|
||||
maxBufferSize = 10,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
recorder.record("one")
|
||||
recorder.record("two")
|
||||
advanceUntilIdle()
|
||||
assertEquals(1, transport.events.size)
|
||||
assertEquals(listOf("one", "two"), transport.events.single().map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryFlushesSparseEventsAfterTheInterval() = runTest {
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = fakePrefs(diagnosticsEnabled = true),
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 20,
|
||||
flushIntervalMillis = 1_000,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
recorder.record("sparse")
|
||||
|
||||
advanceTimeBy(999)
|
||||
runCurrent()
|
||||
assertTrue(transport.eventBatches.isEmpty())
|
||||
advanceTimeBy(1)
|
||||
runCurrent()
|
||||
assertEquals(listOf("sparse"), transport.events.single().map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryCoalescesAutomaticRetriesWithBackoff() = runTest {
|
||||
val transport = RecordingDiagnosticsTransport().apply {
|
||||
eventsResult = Result.failure(IllegalStateException("offline"))
|
||||
}
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = fakePrefs(diagnosticsEnabled = true),
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 1,
|
||||
flushIntervalMillis = 10_000,
|
||||
retryBackoffMillis = 1_000,
|
||||
automaticRetryCount = 1,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
recorder.record("retry")
|
||||
runCurrent()
|
||||
assertEquals(1, transport.eventBatches.size)
|
||||
|
||||
advanceTimeBy(999)
|
||||
runCurrent()
|
||||
assertEquals(1, transport.eventBatches.size)
|
||||
advanceTimeBy(1)
|
||||
runCurrent()
|
||||
assertEquals(2, transport.eventBatches.size)
|
||||
advanceTimeBy(10_000)
|
||||
runCurrent()
|
||||
assertEquals(2, transport.eventBatches.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryFlushesAtMostFiftyEventsPerBatch() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 101,
|
||||
maxBufferSize = 100,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
repeat(75) { recorder.record("event-$it") }
|
||||
|
||||
assertTrue(recorder.flush().isSuccess)
|
||||
assertEquals(listOf(50, 25), transport.eventBatches.map { it.events.size })
|
||||
assertTrue(transport.eventBatches.all { it.events.size <= TelemetryRecorder.MaxEventsPerBatch })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetrySplitsBatchesByEscapedRequestBytes() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val requestBodies = mutableListOf<String>()
|
||||
val transport = HttpDiagnosticsTransport(
|
||||
baseUrl = "https://diag.example",
|
||||
ingestKey = "secret",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
installIdProvider = { "test-install" },
|
||||
post = { _, _, body ->
|
||||
requestBodies += body
|
||||
val id = Regex(""""batchId":"([^"]+)"""").find(body)?.groupValues?.get(1)
|
||||
PlatformHttpResponse(202, """{"ok":true,"id":"$id"}""")
|
||||
},
|
||||
)
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 101,
|
||||
maxBufferSize = 100,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
val properties = LinkedHashMap<String, String>().apply {
|
||||
repeat(MaxDiagnosticProperties) { index ->
|
||||
put("key-$index-${"\u0001".repeat(40)}", "\u0001".repeat(MaxDiagnosticPropertyValueBytes))
|
||||
}
|
||||
}
|
||||
repeat(50) { index ->
|
||||
recorder.record("event-$index-${"\u0001".repeat(64)}", properties)
|
||||
}
|
||||
|
||||
assertTrue(recorder.flush().isSuccess)
|
||||
assertTrue(requestBodies.size > 1)
|
||||
assertTrue(requestBodies.all { it.encodeToByteArray().size <= DiagnosticsJson.MaxRequestBytes })
|
||||
assertEquals(50, requestBodies.sumOf { body -> "\"schemaVersion\"".toRegex().findAll(body).count() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetrySanitizesNamesAndPropertiesToServerByteLimits() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 100,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
val properties = LinkedHashMap<String, String>().apply {
|
||||
repeat(20) { index -> put("key-$index-${"🙂".repeat(20)}", "🙂".repeat(100)) }
|
||||
}
|
||||
|
||||
recorder.record("🙂".repeat(100), properties)
|
||||
assertTrue(recorder.flush().isSuccess)
|
||||
|
||||
val event = transport.eventBatches.single().events.single()
|
||||
assertTrue(event.name.encodeToByteArray().size <= MaxDiagnosticNameBytes)
|
||||
assertEquals(MaxDiagnosticProperties, event.properties.size)
|
||||
assertTrue(event.properties.keys.all { it.encodeToByteArray().size <= MaxDiagnosticPropertyKeyBytes })
|
||||
assertTrue(event.properties.values.all { it.encodeToByteArray().size <= MaxDiagnosticPropertyValueBytes })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryKeepsConcurrentRecordsWithoutExceedingItsBuffer() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 101,
|
||||
maxBufferSize = 100,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
coroutineScope {
|
||||
repeat(100) { index ->
|
||||
launch(Dispatchers.Default) { recorder.record("event-$index") }
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
100,
|
||||
recorder.pendingCount() + transport.eventBatches.sumOf { it.events.size },
|
||||
)
|
||||
assertTrue(recorder.flush().isSuccess)
|
||||
assertEquals(100, transport.eventBatches.sumOf { it.events.size })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryRetryReusesBatchIdAndEvents() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val transport = RecordingDiagnosticsTransport().apply {
|
||||
eventsResult = Result.failure(IllegalStateException("offline"))
|
||||
}
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 10,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
recorder.record("one")
|
||||
recorder.record("two")
|
||||
|
||||
assertTrue(recorder.flush().isFailure)
|
||||
val firstAttempt = transport.eventBatches.single()
|
||||
transport.eventsResult = Result.success(Unit)
|
||||
assertTrue(recorder.flush().isSuccess)
|
||||
|
||||
assertEquals(listOf(firstAttempt, firstAttempt), transport.eventBatches)
|
||||
assertEquals(0, recorder.pendingCount())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryDoesNotRequeuePermanentlyRejectedPayload() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val transport = RecordingDiagnosticsTransport().apply {
|
||||
eventsResult = Result.failure(DiagnosticsHttpException(400, "/v1/events"))
|
||||
}
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 10,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
recorder.record("invalid")
|
||||
|
||||
assertTrue(recorder.flush().isFailure)
|
||||
assertEquals(0, recorder.pendingCount())
|
||||
transport.eventsResult = Result.success(Unit)
|
||||
assertTrue(recorder.flush().isSuccess)
|
||||
assertEquals(1, transport.eventBatches.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun telemetryClearsBufferWhenOptedOut() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val recorder = TelemetryRecorder(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
flushThreshold = 100,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
recorder.record("queued")
|
||||
assertEquals(1, recorder.pendingCount())
|
||||
preferences.setDiagnosticsEnabled(false)
|
||||
advanceUntilIdle()
|
||||
assertEquals(0, recorder.pendingCount())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashCodecRoundTrips() {
|
||||
val original = CrashReport(
|
||||
id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
timestampMillis = 42L,
|
||||
installId = "install",
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
exceptionType = "IllegalStateException",
|
||||
exceptionMessage = "boom\nline\\nliteral\u001fseparator",
|
||||
stackTrace = "stack\ntrace\u001erecord",
|
||||
breadcrumbs = listOf(
|
||||
Breadcrumb("open|send", 1L, mapOf("screen:key" to "send,value|next")),
|
||||
),
|
||||
diagnosticsEnabledAtCapture = true,
|
||||
)
|
||||
val decoded = CrashReportCodec.decode(CrashReportCodec.encode(original))
|
||||
assertEquals(original, decoded)
|
||||
assertNull(CrashReportCodec.decode(CrashReportCodec.encode(original.copy(id = "../../escape"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashCodecMigratesLegacyV1Envelope() {
|
||||
val raw = listOf(
|
||||
"id=bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
"ts=42",
|
||||
"install=legacy-install",
|
||||
"app=0.9",
|
||||
"platform=Desktop",
|
||||
"type=IllegalStateException",
|
||||
"message=first\\nsecond",
|
||||
"stack=frame one\\nframe two",
|
||||
"diag=1",
|
||||
"schema=1",
|
||||
"crumbs=1|opened|screen:send",
|
||||
).joinToString("\u001f")
|
||||
|
||||
val report = requireNotNull(CrashReportCodec.decode(raw))
|
||||
|
||||
assertEquals("first\nsecond", report.exceptionMessage)
|
||||
assertEquals("frame one\nframe two", report.stackTrace)
|
||||
assertEquals(true, report.diagnosticsEnabledAtCapture)
|
||||
assertEquals(
|
||||
listOf(Breadcrumb("opened", 1, mapOf("screen" to "send"))),
|
||||
report.breadcrumbs,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashReporterPersistsAndFlushesOnlyOptedInCrashes() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val store = InMemoryPendingCrashStore()
|
||||
val reporter = CrashReporter(
|
||||
store = store,
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
)
|
||||
reporter.startObservingPreferences()
|
||||
advanceUntilIdle()
|
||||
|
||||
val optedIn = reporter.capture(RuntimeException("a"), diagnosticsEnabledOverride = true)
|
||||
reporter.capture(RuntimeException("b"), diagnosticsEnabledOverride = false)
|
||||
assertEquals(1, store.list().size)
|
||||
|
||||
reporter.flushPending()
|
||||
assertEquals(1, transport.crashes.size)
|
||||
assertEquals(optedIn.id, transport.crashes.single().id)
|
||||
assertTrue(store.list().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashReporterRetainsCrashWhenDeliveryIsUnavailable() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val store = InMemoryPendingCrashStore()
|
||||
val reporter = CrashReporter(
|
||||
store = store,
|
||||
preferencesRepository = preferences,
|
||||
transport = NoOpDiagnosticsTransport(),
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
)
|
||||
reporter.capture(RuntimeException("boom"), diagnosticsEnabledOverride = true)
|
||||
|
||||
reporter.flushPending()
|
||||
assertEquals(1, store.list().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashReporterDropsPermanentPayloadFailuresAndStopsAfterTransientFailures() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val permanentTransport = RecordingDiagnosticsTransport().apply {
|
||||
crashResult = Result.failure(DiagnosticsHttpException(400, "/v1/crashes"))
|
||||
}
|
||||
val permanentStore = InMemoryPendingCrashStore()
|
||||
val permanentReporter = CrashReporter(
|
||||
store = permanentStore,
|
||||
preferencesRepository = preferences,
|
||||
transport = permanentTransport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
)
|
||||
permanentReporter.capture(RuntimeException("invalid"), diagnosticsEnabledOverride = true)
|
||||
permanentReporter.flushPending()
|
||||
assertTrue(permanentStore.list().isEmpty())
|
||||
|
||||
val transientTransport = RecordingDiagnosticsTransport().apply {
|
||||
crashResult = Result.failure(IllegalStateException("offline"))
|
||||
}
|
||||
val transientStore = InMemoryPendingCrashStore()
|
||||
val transientReporter = CrashReporter(
|
||||
store = transientStore,
|
||||
preferencesRepository = preferences,
|
||||
transport = transientTransport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
)
|
||||
repeat(2) {
|
||||
transientReporter.capture(RuntimeException("offline-$it"), diagnosticsEnabledOverride = true)
|
||||
}
|
||||
transientReporter.flushPending()
|
||||
assertEquals(1, transientTransport.crashes.size)
|
||||
assertEquals(2, transientStore.list().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashReporterResolvesStartupConsentBeforeUploading() = runTest {
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val store = InMemoryPendingCrashStore()
|
||||
val reporter = CrashReporter(
|
||||
store = store,
|
||||
preferencesRepository = fakePrefs(diagnosticsEnabled = true),
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
)
|
||||
val startupCrash = reporter.capture(RuntimeException("startup"))
|
||||
assertNull(startupCrash.diagnosticsEnabledAtCapture)
|
||||
|
||||
reporter.flushPending()
|
||||
|
||||
assertEquals(true, transport.crashes.single().diagnosticsEnabledAtCapture)
|
||||
assertEquals("test-install", transport.crashes.single().installId)
|
||||
assertTrue(store.list().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashReporterDoesNotRetroactivelyUploadAnOptedOutStartupCrash() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = false)
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val store = InMemoryPendingCrashStore()
|
||||
val reporter = CrashReporter(
|
||||
store = store,
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
)
|
||||
reporter.capture(RuntimeException("startup"))
|
||||
|
||||
reporter.flushPending()
|
||||
assertTrue(store.list().isEmpty())
|
||||
preferences.setDiagnosticsEnabled(true)
|
||||
reporter.flushPending()
|
||||
|
||||
assertTrue(transport.crashes.isEmpty())
|
||||
assertTrue(store.list().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashReporterStopsAFlushWhenTheUserOptsOut() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = true)
|
||||
val firstSendStarted = CompletableDeferred<Unit>()
|
||||
val releaseFirstSend = CompletableDeferred<Unit>()
|
||||
val sentIds = mutableListOf<String>()
|
||||
val transport = object : DiagnosticsTransport {
|
||||
override suspend fun sendEvents(batch: TelemetryBatch) = Result.success(Unit)
|
||||
override suspend fun sendBugReport(report: BugReport) = Result.success(Unit)
|
||||
override suspend fun sendCrash(report: CrashReport): Result<Unit> {
|
||||
sentIds += report.id
|
||||
if (sentIds.size == 1) {
|
||||
firstSendStarted.complete(Unit)
|
||||
releaseFirstSend.await()
|
||||
}
|
||||
return Result.success(Unit)
|
||||
}
|
||||
}
|
||||
val store = InMemoryPendingCrashStore()
|
||||
val reporter = CrashReporter(
|
||||
store = store,
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
scope = TestScope(UnconfinedTestDispatcher(testScheduler)),
|
||||
)
|
||||
reporter.startObservingPreferences()
|
||||
advanceUntilIdle()
|
||||
repeat(2) {
|
||||
reporter.capture(RuntimeException("crash-$it"), diagnosticsEnabledOverride = true)
|
||||
}
|
||||
|
||||
val flush = launch { reporter.flushPending() }
|
||||
firstSendStarted.await()
|
||||
preferences.setDiagnosticsEnabled(false)
|
||||
runCurrent()
|
||||
releaseFirstSend.complete(Unit)
|
||||
flush.join()
|
||||
|
||||
assertEquals(1, sentIds.size)
|
||||
assertTrue(store.list().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bugReportRequiresWhatAndExpected() = runTest {
|
||||
val preferences = fakePrefs()
|
||||
val service = BugReportService(
|
||||
preferencesRepository = preferences,
|
||||
transport = RecordingDiagnosticsTransport(),
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
logReader = { "logs" },
|
||||
)
|
||||
assertTrue(service.submit(BugReportDraft("", "expected"), device()).isFailure)
|
||||
assertTrue(service.submit(BugReportDraft("what", ""), device()).isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bugReportConvertsTransportExceptionsToFailure() = runTest {
|
||||
val throwingTransport = object : DiagnosticsTransport {
|
||||
override suspend fun sendEvents(batch: TelemetryBatch) = Result.success(Unit)
|
||||
override suspend fun sendCrash(report: CrashReport) = Result.success(Unit)
|
||||
override suspend fun sendBugReport(report: BugReport): Result<Unit> {
|
||||
throw IllegalStateException("offline")
|
||||
}
|
||||
}
|
||||
val service = BugReportService(
|
||||
preferencesRepository = fakePrefs(),
|
||||
transport = throwingTransport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
)
|
||||
|
||||
val result = service.submit(BugReportDraft("what", "expected"), device())
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals("offline", result.exceptionOrNull()?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bugReportSubmitsWithRedactedLogsRegardlessOfDiagnostics() = runTest {
|
||||
val preferences = fakePrefs(diagnosticsEnabled = false)
|
||||
val transport = RecordingDiagnosticsTransport()
|
||||
val service = BugReportService(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
logReader = { LogRedactor.redact("ticket=abcdefghijklmnopqrstuvwxyz012345 plain") },
|
||||
)
|
||||
val result = service.submit(
|
||||
BugReportDraft(
|
||||
whatHappened = "crash on receive",
|
||||
expected = "receive succeeds",
|
||||
steps = "open ticket",
|
||||
contact = "user@example.com",
|
||||
includeLogs = true,
|
||||
),
|
||||
device(),
|
||||
)
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(1, transport.bugReports.size)
|
||||
val report = transport.bugReports.single()
|
||||
assertEquals("crash on receive", report.whatHappened)
|
||||
assertTrue(report.logs.contains("[redacted-ticket]"))
|
||||
assertFalse(report.logs.contains("abcdefghijklmnopqrstuvwxyz012345"))
|
||||
assertEquals("test-install", report.installId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bugReportRedactsLogsBeforeApplyingUtf8Limit() {
|
||||
val secret = "abcdefghijklmnopqrstuvwxyz012345"
|
||||
val rawLogs = "ticket=$secret\n".repeat(6_000) + "tail-marker"
|
||||
val service = BugReportService(
|
||||
preferencesRepository = fakePrefs(),
|
||||
transport = RecordingDiagnosticsTransport(),
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
logReader = { rawLogs },
|
||||
)
|
||||
|
||||
val logs = service.assemble(BugReportDraft("what", "expected"), device(), "install").logs
|
||||
|
||||
assertFalse(logs.contains(secret))
|
||||
assertTrue(logs.endsWith("tail-marker"))
|
||||
assertTrue(logs.encodeToByteArray().size <= BugReportService.MaxLogBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bugReportLogLimitCountsUtf8Bytes() {
|
||||
val service = BugReportService(
|
||||
preferencesRepository = fakePrefs(),
|
||||
transport = RecordingDiagnosticsTransport(),
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
logReader = { "🙂".repeat(60_000) },
|
||||
)
|
||||
|
||||
val logs = service.assemble(BugReportDraft("what", "expected"), device(), "install").logs
|
||||
|
||||
assertEquals(BugReportService.MaxLogBytes, logs.encodeToByteArray().size)
|
||||
assertEquals(BugReportService.MaxLogBytes, service.previewLogBytes())
|
||||
}
|
||||
|
||||
private fun fakePrefs(diagnosticsEnabled: Boolean = false) = FakePreferencesRepository(
|
||||
AppPreferences(
|
||||
username = "User",
|
||||
receiveFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp"),
|
||||
themeMode = ThemeMode.System,
|
||||
notificationsEnabled = false,
|
||||
diagnosticsEnabled = diagnosticsEnabled,
|
||||
diagnosticsInstallId = "test-install",
|
||||
),
|
||||
)
|
||||
|
||||
private fun device() = DeviceInfo("Phone", "Pixel", "Android 15", "Wi-Fi", "90%")
|
||||
}
|
||||
|
||||
private class InMemoryPendingCrashStore : PendingCrashStore {
|
||||
private val items = linkedMapOf<String, CrashReport>()
|
||||
override fun write(report: CrashReport) {
|
||||
items[report.id] = report
|
||||
}
|
||||
override fun list(): List<CrashReport> = items.values.sortedByDescending { it.timestampMillis }
|
||||
override fun delete(id: String) {
|
||||
items.remove(id)
|
||||
}
|
||||
override fun prune(olderThanTimestampMillis: Long, maxCount: Int) {
|
||||
items.values
|
||||
.sortedByDescending { it.timestampMillis }
|
||||
.drop(maxCount)
|
||||
.map(CrashReport::id)
|
||||
.forEach(items::remove)
|
||||
items.values
|
||||
.filter { it.timestampMillis < olderThanTimestampMillis }
|
||||
.map(CrashReport::id)
|
||||
.forEach(items::remove)
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,20 @@ import com.vnidrop.app.core.ShareAccessPolicy
|
||||
import com.vnidrop.app.core.Transfer
|
||||
import com.vnidrop.app.core.TransferDirection
|
||||
import com.vnidrop.app.core.TransferStatus
|
||||
import com.vnidrop.app.diagnostics.BreadcrumbBuffer
|
||||
import com.vnidrop.app.diagnostics.BugReportService
|
||||
import com.vnidrop.app.diagnostics.DiagnosticsTransport
|
||||
import com.vnidrop.app.diagnostics.NoOpDiagnosticsTransport
|
||||
import com.vnidrop.app.diagnostics.RecordingDiagnosticsTransport
|
||||
import com.vnidrop.app.feature.app.AppViewModel
|
||||
import com.vnidrop.app.feature.receive.ReceiveHistoryDeleteTarget
|
||||
import com.vnidrop.app.feature.receive.ReceiveViewModel
|
||||
import com.vnidrop.app.feature.send.SendViewModel
|
||||
import com.vnidrop.app.feature.settings.SettingsSection
|
||||
import com.vnidrop.app.feature.settings.SettingsViewModel
|
||||
import com.vnidrop.app.notifications.NotificationPermission
|
||||
import com.vnidrop.app.preferences.AppPreferences
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.support.FakeCoreGateway
|
||||
import com.vnidrop.app.support.FakeFileSystemService
|
||||
import com.vnidrop.app.support.FakeFilePreviewRepository
|
||||
@@ -62,14 +69,7 @@ class ViewModelsTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val preferences = preferences()
|
||||
val notifications = FakeNotificationService(NotificationPermission.Granted)
|
||||
val viewModel = SettingsViewModel(
|
||||
environment(),
|
||||
{ DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") },
|
||||
FakeFileSystemService(folder),
|
||||
preferences,
|
||||
notifications,
|
||||
UiMessageController(),
|
||||
)
|
||||
val viewModel = settingsViewModel(preferences, notifications)
|
||||
advanceUntilIdle()
|
||||
viewModel.setNotificationsEnabled(true)
|
||||
advanceUntilIdle()
|
||||
@@ -84,14 +84,7 @@ class ViewModelsTest {
|
||||
fun settingsUsernameKeepsSpacesWhileTypingAndPersistsAfterDebounce() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val preferences = preferences()
|
||||
val viewModel = SettingsViewModel(
|
||||
environment(),
|
||||
{ DeviceInfo("Device", null, "OS", null, null) },
|
||||
FakeFileSystemService(folder),
|
||||
preferences,
|
||||
FakeNotificationService(),
|
||||
UiMessageController(),
|
||||
)
|
||||
val viewModel = settingsViewModel(preferences)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.setUsername("Ada ")
|
||||
@@ -109,14 +102,7 @@ class ViewModelsTest {
|
||||
fun settingsKeepsNotificationsDisabledWhenPermissionIsDenied() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val preferences = preferences()
|
||||
val viewModel = SettingsViewModel(
|
||||
environment(),
|
||||
{ DeviceInfo("Device", null, "OS", null, null) },
|
||||
FakeFileSystemService(folder),
|
||||
preferences,
|
||||
FakeNotificationService(NotificationPermission.Denied),
|
||||
UiMessageController(),
|
||||
)
|
||||
val viewModel = settingsViewModel(preferences, FakeNotificationService(NotificationPermission.Denied))
|
||||
advanceUntilIdle()
|
||||
viewModel.setNotificationsEnabled(true)
|
||||
advanceUntilIdle()
|
||||
@@ -128,14 +114,7 @@ class ViewModelsTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val preferences = preferences()
|
||||
val notifications = FakeNotificationService(NotificationPermission.Denied)
|
||||
val viewModel = SettingsViewModel(
|
||||
environment(),
|
||||
{ DeviceInfo("Device", null, "OS", null, null) },
|
||||
FakeFileSystemService(folder),
|
||||
preferences,
|
||||
notifications,
|
||||
UiMessageController(),
|
||||
)
|
||||
val viewModel = settingsViewModel(preferences, notifications)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.openNotificationSettings()
|
||||
@@ -154,14 +133,7 @@ class ViewModelsTest {
|
||||
fun settingsReportsUnsupportedNotificationPlatforms() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val preferences = preferences()
|
||||
val viewModel = SettingsViewModel(
|
||||
environment(),
|
||||
{ DeviceInfo("Device", null, "OS", null, null) },
|
||||
FakeFileSystemService(folder),
|
||||
preferences,
|
||||
FakeNotificationService(NotificationPermission.Unsupported),
|
||||
UiMessageController(),
|
||||
)
|
||||
val viewModel = settingsViewModel(preferences, FakeNotificationService(NotificationPermission.Unsupported))
|
||||
advanceUntilIdle()
|
||||
viewModel.setNotificationsEnabled(true)
|
||||
advanceUntilIdle()
|
||||
@@ -169,6 +141,51 @@ class ViewModelsTest {
|
||||
assertEquals(NotificationPermission.Unsupported, viewModel.state.value.notificationPermission)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsTogglesDiagnosticsPreference() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val preferences = preferences()
|
||||
val viewModel = settingsViewModel(preferences)
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.state.value.diagnosticsEnabled)
|
||||
viewModel.setDiagnosticsEnabled(true)
|
||||
advanceUntilIdle()
|
||||
assertTrue(preferences.mutablePreferences.value.diagnosticsEnabled)
|
||||
assertTrue(viewModel.state.value.diagnosticsEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsSubmitsBugReportAndClearsForm() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val preferences = preferences()
|
||||
val viewModel = settingsViewModel(preferences)
|
||||
advanceUntilIdle()
|
||||
viewModel.setBugWhatHappened("Transfer stuck")
|
||||
viewModel.setBugExpected("It should finish")
|
||||
viewModel.submitBugReport()
|
||||
advanceUntilIdle()
|
||||
assertEquals("", viewModel.state.value.bugWhatHappened)
|
||||
assertEquals("", viewModel.state.value.bugExpected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsKeepsBugReportWhenDeliveryIsUnavailable() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val viewModel = settingsViewModel(transport = NoOpDiagnosticsTransport())
|
||||
advanceUntilIdle()
|
||||
viewModel.selectSection(SettingsSection.BugReport)
|
||||
viewModel.setBugWhatHappened("Transfer stuck")
|
||||
viewModel.setBugExpected("It should finish")
|
||||
|
||||
viewModel.submitBugReport()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("Transfer stuck", viewModel.state.value.bugWhatHappened)
|
||||
assertEquals("It should finish", viewModel.state.value.bugExpected)
|
||||
assertEquals(SettingsSection.BugReport, viewModel.state.value.selectedSection)
|
||||
assertFalse(viewModel.state.value.isSubmittingBugReport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendViewModelOwnsSelectedFileState() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
@@ -501,11 +518,39 @@ class ViewModelsTest {
|
||||
}
|
||||
|
||||
private fun preferences() = FakePreferencesRepository(
|
||||
AppPreferences("Receiver", folder, ThemeMode.System, notificationsEnabled = false),
|
||||
AppPreferences(
|
||||
username = "Receiver",
|
||||
receiveFolder = folder,
|
||||
themeMode = ThemeMode.System,
|
||||
notificationsEnabled = false,
|
||||
diagnosticsEnabled = false,
|
||||
diagnosticsInstallId = "test-install",
|
||||
),
|
||||
)
|
||||
|
||||
private fun environment() = PlatformEnvironment("Test", "1.0", "/tmp/vnidrop")
|
||||
|
||||
private fun settingsViewModel(
|
||||
preferences: PreferencesRepository = preferences(),
|
||||
notifications: FakeNotificationService = FakeNotificationService(),
|
||||
transport: DiagnosticsTransport = RecordingDiagnosticsTransport(),
|
||||
) = SettingsViewModel(
|
||||
environment(),
|
||||
{ DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") },
|
||||
FakeFileSystemService(folder),
|
||||
preferences,
|
||||
notifications,
|
||||
UiMessageController(),
|
||||
BugReportService(
|
||||
preferencesRepository = preferences,
|
||||
transport = transport,
|
||||
breadcrumbs = BreadcrumbBuffer(),
|
||||
appVersion = "1.0",
|
||||
platform = "Test",
|
||||
logReader = { "sample log line" },
|
||||
),
|
||||
)
|
||||
|
||||
private fun receivedTransfer(id: ULong, status: TransferStatus) = Transfer(
|
||||
localId = "receive-$id",
|
||||
transferId = id,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.vnidrop.app.feature.send
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class TransferQrCodeTest {
|
||||
@Test
|
||||
fun ticketPastVersionTwentyCapacityUsesNextAvailableVersion() {
|
||||
assertEquals(20, transferQrInformationDensity("a".repeat(858)))
|
||||
assertEquals(21, transferQrInformationDensity("a".repeat(859)))
|
||||
assertEquals(21, transferQrInformationDensity("a".repeat(876)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renderedQrUsesExpectedVersionSizeAndQuietZone() {
|
||||
val qrCode = buildTransferQrCode("a".repeat(876))
|
||||
|
||||
assertEquals(21, qrCode.informationDensity)
|
||||
assertEquals(101, qrCode.rawData.size)
|
||||
assertEquals(872, qrCode.canvasSize)
|
||||
}
|
||||
}
|
||||
@@ -197,6 +197,16 @@ class FakePreferencesRepository(
|
||||
override suspend fun resetReceiveFolder() = Unit
|
||||
override suspend fun setThemeMode(mode: ThemeMode) { mutablePreferences.value = mutablePreferences.value.copy(themeMode = mode) }
|
||||
override suspend fun setNotificationsEnabled(enabled: Boolean) { mutablePreferences.value = mutablePreferences.value.copy(notificationsEnabled = enabled) }
|
||||
override suspend fun setDiagnosticsEnabled(enabled: Boolean) {
|
||||
mutablePreferences.value = mutablePreferences.value.copy(diagnosticsEnabled = enabled)
|
||||
}
|
||||
override suspend fun ensureDiagnosticsInstallId(): String {
|
||||
val existing = mutablePreferences.value.diagnosticsInstallId
|
||||
if (existing.isNotBlank()) return existing
|
||||
val created = "test-install-id"
|
||||
mutablePreferences.value = mutablePreferences.value.copy(diagnosticsInstallId = created)
|
||||
return created
|
||||
}
|
||||
}
|
||||
|
||||
class FakeNotificationService(
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import kotlinx.cinterop.BetaInteropApi
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.convert
|
||||
import kotlinx.cinterop.usePinned
|
||||
import platform.Foundation.NSData
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.create
|
||||
import platform.Foundation.dataWithContentsOfFile
|
||||
import platform.Foundation.writeToFile
|
||||
import platform.posix.memcpy
|
||||
|
||||
actual fun createPendingCrashStore(appDataDir: String): PendingCrashStore =
|
||||
IosPendingCrashStore(appDataDir)
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private class IosPendingCrashStore(
|
||||
appDataDir: String,
|
||||
) : PendingCrashStore {
|
||||
private val fileManager = NSFileManager.defaultManager
|
||||
private val directory = appDataDir.trimEnd('/') + "/diagnostics/crashes"
|
||||
|
||||
override fun write(report: CrashReport) {
|
||||
if (!isValidDiagnosticId(report.id)) return
|
||||
ensureDirectory()
|
||||
val path = "$directory/${report.id}.crash"
|
||||
val payload = CrashReportCodec.encode(report)
|
||||
val data = payload.encodeToByteArray().toNSData()
|
||||
data.writeToFile(path, atomically = true)
|
||||
}
|
||||
|
||||
override fun list(): List<CrashReport> {
|
||||
ensureDirectory()
|
||||
val names = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty()
|
||||
.filterIsInstance<String>()
|
||||
.filter { it.endsWith(".crash") }
|
||||
return names.mapNotNull { name ->
|
||||
val path = "$directory/$name"
|
||||
val data = NSData.dataWithContentsOfFile(path) ?: return@mapNotNull null
|
||||
val text = data.toUtf8String()
|
||||
CrashReportCodec.decode(text)
|
||||
}.sortedByDescending { it.timestampMillis }
|
||||
}
|
||||
|
||||
override fun delete(id: String) {
|
||||
if (!isValidDiagnosticId(id)) return
|
||||
fileManager.removeItemAtPath("$directory/$id.crash", null)
|
||||
}
|
||||
|
||||
override fun prune(olderThanTimestampMillis: Long, maxCount: Int) {
|
||||
require(maxCount > 0) { "maxCount must be positive" }
|
||||
ensureDirectory()
|
||||
val reports = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty()
|
||||
.filterIsInstance<String>()
|
||||
.filter { it.endsWith(".crash") }
|
||||
.mapNotNull { name ->
|
||||
val path = "$directory/$name"
|
||||
val report = NSData.dataWithContentsOfFile(path)
|
||||
?.toUtf8String()
|
||||
?.let(CrashReportCodec::decode)
|
||||
if (report == null) {
|
||||
fileManager.removeItemAtPath(path, null)
|
||||
null
|
||||
} else {
|
||||
name to report
|
||||
}
|
||||
}
|
||||
.sortedByDescending { (_, report) -> report.timestampMillis }
|
||||
reports.forEachIndexed { index, (name, report) ->
|
||||
if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) {
|
||||
fileManager.removeItemAtPath("$directory/$name", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureDirectory() {
|
||||
fileManager.createDirectoryAtPath(directory, withIntermediateDirectories = true, attributes = null, error = null)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||
private fun ByteArray.toNSData(): NSData =
|
||||
usePinned { pinned ->
|
||||
NSData.create(bytes = pinned.addressOf(0), length = size.toULong())
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun NSData.toUtf8String(): String {
|
||||
val size = length.toInt()
|
||||
if (size == 0) return ""
|
||||
val result = ByteArray(size)
|
||||
val source = bytes ?: return ""
|
||||
result.usePinned { pinned ->
|
||||
memcpy(pinned.addressOf(0), source, size.convert())
|
||||
}
|
||||
return result.decodeToString()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import kotlin.experimental.ExperimentalNativeApi
|
||||
|
||||
@OptIn(ExperimentalNativeApi::class)
|
||||
actual fun installPlatformCrashHook(onCrash: (Throwable) -> Unit) {
|
||||
val previous = setUnhandledExceptionHook { throwable ->
|
||||
runCatching { onCrash(throwable) }
|
||||
// Terminate like the default hook after capture.
|
||||
terminateWithUnhandledException(throwable)
|
||||
}
|
||||
// Keep a reference so the previous hook is not GC'd unused; we intentionally
|
||||
// replace the default with capture-then-terminate.
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val ignored = previous
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import kotlinx.cinterop.BetaInteropApi
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.convert
|
||||
import kotlinx.cinterop.usePinned
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import platform.Foundation.NSData
|
||||
import platform.Foundation.NSHTTPURLResponse
|
||||
import platform.Foundation.NSMutableURLRequest
|
||||
import platform.Foundation.NSURL
|
||||
import platform.Foundation.NSURLSession
|
||||
import platform.Foundation.create
|
||||
import platform.Foundation.dataTaskWithRequest
|
||||
import platform.Foundation.setHTTPBody
|
||||
import platform.Foundation.setHTTPMethod
|
||||
import platform.Foundation.setValue
|
||||
import platform.posix.memcpy
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual suspend fun platformHttpPost(
|
||||
url: String,
|
||||
headers: Map<String, String>,
|
||||
bodyUtf8: String,
|
||||
): PlatformHttpResponse = suspendCancellableCoroutine { cont ->
|
||||
val nsUrl = NSURL.URLWithString(url)
|
||||
if (nsUrl == null) {
|
||||
cont.resume(PlatformHttpResponse(statusCode = 0, body = "invalid_url"))
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
val request = NSMutableURLRequest.requestWithURL(nsUrl).apply {
|
||||
setHTTPMethod("POST")
|
||||
setValue("application/json; charset=utf-8", forHTTPHeaderField = "Content-Type")
|
||||
headers.forEach { (key, value) ->
|
||||
setValue(value, forHTTPHeaderField = key)
|
||||
}
|
||||
setHTTPBody(bodyUtf8.encodeToByteArray().toNSData())
|
||||
}
|
||||
val task = NSURLSession.sharedSession.dataTaskWithRequest(request) { data, response, error ->
|
||||
if (!cont.isActive) return@dataTaskWithRequest
|
||||
if (error != null) {
|
||||
val message = error.localizedDescription
|
||||
cont.resume(PlatformHttpResponse(statusCode = 0, body = message))
|
||||
return@dataTaskWithRequest
|
||||
}
|
||||
val http = response as? NSHTTPURLResponse
|
||||
val status = http?.statusCode?.toInt() ?: 0
|
||||
val body = data?.toUtf8String().orEmpty()
|
||||
cont.resume(PlatformHttpResponse(statusCode = status, body = body))
|
||||
}
|
||||
cont.invokeOnCancellation { task.cancel() }
|
||||
task.resume()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||
private fun ByteArray.toNSData(): NSData =
|
||||
usePinned { pinned ->
|
||||
NSData.create(bytes = pinned.addressOf(0), length = size.toULong())
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun NSData.toUtf8String(): String {
|
||||
val size = length.toInt()
|
||||
if (size == 0) return ""
|
||||
val result = ByteArray(size)
|
||||
val source = bytes ?: return ""
|
||||
result.usePinned { pinned ->
|
||||
memcpy(pinned.addressOf(0), source, size.convert())
|
||||
}
|
||||
return result.decodeToString()
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
package com.vnidrop.app.logging
|
||||
|
||||
import platform.Foundation.NSData
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.convert
|
||||
import kotlinx.cinterop.usePinned
|
||||
import platform.Foundation.NSData
|
||||
import platform.Foundation.NSDate
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSFileModificationDate
|
||||
import platform.Foundation.NSFileSize
|
||||
import platform.Foundation.NSNumber
|
||||
import platform.Foundation.dataWithContentsOfFile
|
||||
import platform.Foundation.timeIntervalSince1970
|
||||
import platform.posix.fclose
|
||||
import platform.posix.fopen
|
||||
import platform.posix.fwrite
|
||||
import platform.posix.memcpy
|
||||
|
||||
actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore =
|
||||
IosPlatformLogStore(appDataDir, policy)
|
||||
@@ -61,6 +63,31 @@ private class IosPlatformLogStore(
|
||||
.sortedByDescending { it.modifiedAtMillis }
|
||||
}
|
||||
|
||||
override fun readLatest(maxBytes: Long): String {
|
||||
if (maxBytes <= 0) return ""
|
||||
ensureDirectory()
|
||||
val paths = listOf(activePath) +
|
||||
(1..policy.maxFiles).map { "$directory/app.$it.log" }
|
||||
val chunks = ArrayList<ByteArray>()
|
||||
var remaining = maxBytes
|
||||
for (path in paths) {
|
||||
if (remaining <= 0 || !fileManager.fileExistsAtPath(path)) continue
|
||||
val slice = readTail(path, remaining)
|
||||
if (slice.isEmpty()) continue
|
||||
chunks.add(0, slice)
|
||||
remaining -= slice.size.toLong()
|
||||
}
|
||||
if (chunks.isEmpty()) return ""
|
||||
val total = chunks.sumOf { it.size }
|
||||
val out = ByteArray(total)
|
||||
var offset = 0
|
||||
for (chunk in chunks) {
|
||||
chunk.copyInto(out, offset)
|
||||
offset += chunk.size
|
||||
}
|
||||
return out.decodeToString()
|
||||
}
|
||||
|
||||
private fun rotate() {
|
||||
if (policy.maxFiles == 0) {
|
||||
fileManager.removeItemAtPath(activePath, null)
|
||||
@@ -92,4 +119,31 @@ private class IosPlatformLogStore(
|
||||
val date = attributes[NSFileModificationDate] as? NSDate ?: return 0L
|
||||
return (date.timeIntervalSince1970 * 1000.0).toLong()
|
||||
}
|
||||
|
||||
private fun readTail(path: String, maxBytes: Long): ByteArray {
|
||||
val data = NSData.dataWithContentsOfFile(path) ?: return ByteArray(0)
|
||||
val all = data.toByteArray()
|
||||
if (all.isEmpty() || maxBytes <= 0) return ByteArray(0)
|
||||
if (all.size.toLong() <= maxBytes) return all
|
||||
val start = all.size - maxBytes.toInt()
|
||||
val slice = all.copyOfRange(start, all.size)
|
||||
val newline = slice.indexOf('\n'.code.toByte())
|
||||
return if (newline in 0 until slice.lastIndex) {
|
||||
slice.copyOfRange(newline + 1, slice.size)
|
||||
} else {
|
||||
slice
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun NSData.toByteArray(): ByteArray {
|
||||
val size = length.toInt()
|
||||
if (size == 0) return ByteArray(0)
|
||||
val result = ByteArray(size)
|
||||
val source = bytes ?: return ByteArray(0)
|
||||
result.usePinned { pinned ->
|
||||
memcpy(pinned.addressOf(0), source, size.convert())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
actual fun createPendingCrashStore(appDataDir: String): PendingCrashStore =
|
||||
JvmPendingCrashStore(appDataDir)
|
||||
|
||||
private class JvmPendingCrashStore(
|
||||
appDataDir: String,
|
||||
) : PendingCrashStore {
|
||||
private val directory = File(appDataDir, "diagnostics/crashes")
|
||||
|
||||
@Synchronized
|
||||
override fun write(report: CrashReport) {
|
||||
if (!isValidDiagnosticId(report.id)) return
|
||||
directory.mkdirs()
|
||||
val target = File(directory, "${report.id}.crash")
|
||||
val temporary = File(directory, ".${report.id}.tmp")
|
||||
val payload = CrashReportCodec.encode(report)
|
||||
temporary.writeText(payload, StandardCharsets.UTF_8)
|
||||
if (!temporary.renameTo(target)) {
|
||||
target.writeText(payload, StandardCharsets.UTF_8)
|
||||
temporary.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun list(): List<CrashReport> {
|
||||
if (!directory.isDirectory) return emptyList()
|
||||
return directory
|
||||
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
|
||||
.orEmpty()
|
||||
.sortedByDescending { it.lastModified() }
|
||||
.mapNotNull { file ->
|
||||
runCatching { CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8)) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun delete(id: String) {
|
||||
if (!isValidDiagnosticId(id)) return
|
||||
File(directory, "$id.crash").delete()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun prune(olderThanTimestampMillis: Long, maxCount: Int) {
|
||||
require(maxCount > 0) { "maxCount must be positive" }
|
||||
if (!directory.isDirectory) return
|
||||
directory.listFiles { file -> file.isFile && file.name.endsWith(".tmp") }
|
||||
.orEmpty()
|
||||
.forEach(File::delete)
|
||||
val reports = directory
|
||||
.listFiles { file -> file.isFile && file.name.endsWith(".crash") }
|
||||
.orEmpty()
|
||||
.mapNotNull { file ->
|
||||
val report = runCatching {
|
||||
CrashReportCodec.decode(file.readText(StandardCharsets.UTF_8))
|
||||
}.getOrNull()
|
||||
if (report == null) {
|
||||
file.delete()
|
||||
null
|
||||
} else {
|
||||
file to report
|
||||
}
|
||||
}
|
||||
.sortedByDescending { (_, report) -> report.timestampMillis }
|
||||
reports.forEachIndexed { index, (file, report) ->
|
||||
if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
actual fun installPlatformCrashHook(onCrash: (Throwable) -> Unit) {
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
runCatching { onCrash(throwable) }
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URI
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
actual suspend fun platformHttpPost(
|
||||
url: String,
|
||||
headers: Map<String, String>,
|
||||
bodyUtf8: String,
|
||||
): PlatformHttpResponse = withContext(Dispatchers.IO) {
|
||||
val connection = (URI(url).toURL().openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
doOutput = true
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 30_000
|
||||
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
headers.forEach { (key, value) -> setRequestProperty(key, value) }
|
||||
}
|
||||
try {
|
||||
connection.outputStream.use { output ->
|
||||
output.write(bodyUtf8.toByteArray(StandardCharsets.UTF_8))
|
||||
}
|
||||
val code = connection.responseCode
|
||||
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
|
||||
val body = stream?.use { input ->
|
||||
BufferedReader(InputStreamReader(input, StandardCharsets.UTF_8)).readText()
|
||||
}.orEmpty()
|
||||
PlatformHttpResponse(code, body)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vnidrop.app.logging
|
||||
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore =
|
||||
@@ -37,6 +38,32 @@ private class JvmPlatformLogStore(
|
||||
.map { file -> LogFileInfo(file.name, file.absolutePath, file.length(), file.lastModified()) }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun readLatest(maxBytes: Long): String {
|
||||
if (maxBytes <= 0) return ""
|
||||
directory.mkdirs()
|
||||
val files = listOf(activeFile) +
|
||||
(1..policy.maxFiles).map { File(directory, "app.$it.log") }
|
||||
val chunks = ArrayList<ByteArray>()
|
||||
var remaining = maxBytes
|
||||
for (file in files) {
|
||||
if (remaining <= 0 || !file.isFile) continue
|
||||
val slice = readTail(file, remaining)
|
||||
if (slice.isEmpty()) continue
|
||||
chunks.add(0, slice)
|
||||
remaining -= slice.size.toLong()
|
||||
}
|
||||
if (chunks.isEmpty()) return ""
|
||||
val total = chunks.sumOf { it.size }
|
||||
val out = ByteArray(total)
|
||||
var offset = 0
|
||||
for (chunk in chunks) {
|
||||
chunk.copyInto(out, offset)
|
||||
offset += chunk.size
|
||||
}
|
||||
return String(out, StandardCharsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun rotate() {
|
||||
if (policy.maxFiles == 0) {
|
||||
activeFile.delete()
|
||||
@@ -54,3 +81,23 @@ private class JvmPlatformLogStore(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readTail(file: File, maxBytes: Long): ByteArray {
|
||||
if (!file.isFile || file.length() == 0L || maxBytes <= 0) return ByteArray(0)
|
||||
val length = file.length()
|
||||
val start = (length - maxBytes).coerceAtLeast(0L)
|
||||
val size = (length - start).toInt()
|
||||
RandomAccessFile(file, "r").use { raf ->
|
||||
raf.seek(start)
|
||||
val bytes = ByteArray(size)
|
||||
raf.readFully(bytes)
|
||||
if (start == 0L) return bytes
|
||||
// Align to the next full line when we start mid-file.
|
||||
val newline = bytes.indexOf('\n'.code.toByte())
|
||||
return if (newline in 0 until bytes.lastIndex) {
|
||||
bytes.copyOfRange(newline + 1, bytes.size)
|
||||
} else {
|
||||
bytes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.vnidrop.app.diagnostics
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PendingCrashStoreJvmTest {
|
||||
@Test
|
||||
fun replacesReportsAndPrunesOldCorruptAndTemporaryFiles() {
|
||||
val root = Files.createTempDirectory("vnidrop-crash-store").toFile()
|
||||
try {
|
||||
val store = createPendingCrashStore(root.absolutePath)
|
||||
val older = report("10000000-0000-4000-8000-000000000001", 1, "older")
|
||||
val current = report("10000000-0000-4000-8000-000000000002", 2, "current")
|
||||
store.write(older)
|
||||
store.write(current)
|
||||
store.write(current.copy(exceptionMessage = "replaced"))
|
||||
|
||||
val directory = File(root, "diagnostics/crashes")
|
||||
File(directory, "corrupt.crash").writeText("not a crash envelope")
|
||||
File(directory, ".orphan.tmp").writeText("partial")
|
||||
store.write(current.copy(id = "../../escape"))
|
||||
val escapedPath = File(directory, "../../escape.crash").canonicalFile
|
||||
|
||||
assertEquals(
|
||||
listOf("replaced", "older"),
|
||||
store.list().map(CrashReport::exceptionMessage),
|
||||
)
|
||||
store.prune(olderThanTimestampMillis = 0, maxCount = 1)
|
||||
|
||||
assertEquals(listOf("replaced"), store.list().map(CrashReport::exceptionMessage))
|
||||
assertFalse(File(directory, "corrupt.crash").exists())
|
||||
assertFalse(File(directory, ".orphan.tmp").exists())
|
||||
assertFalse(escapedPath.exists())
|
||||
assertTrue(directory.listFiles().orEmpty().all { it.parentFile == directory })
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun report(id: String, timestampMillis: Long, message: String) = CrashReport(
|
||||
id = id,
|
||||
timestampMillis = timestampMillis,
|
||||
installId = "install",
|
||||
appVersion = "1.0",
|
||||
platform = "Desktop",
|
||||
exceptionType = "TestError",
|
||||
exceptionMessage = message,
|
||||
stackTrace = "stack",
|
||||
breadcrumbs = emptyList(),
|
||||
diagnosticsEnabledAtCapture = true,
|
||||
)
|
||||
}
|
||||
@@ -86,6 +86,13 @@ class FoundationComposeTest {
|
||||
onResetFolder = {},
|
||||
onNotificationsChanged = {},
|
||||
onOpenNotificationSettings = {},
|
||||
onDiagnosticsChanged = {},
|
||||
onBugWhatChanged = {},
|
||||
onBugExpectedChanged = {},
|
||||
onBugStepsChanged = {},
|
||||
onBugContactChanged = {},
|
||||
onBugIncludeLogsChanged = {},
|
||||
onSubmitBugReport = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -108,6 +115,13 @@ class FoundationComposeTest {
|
||||
onResetFolder = {},
|
||||
onNotificationsChanged = { enabled = it },
|
||||
onOpenNotificationSettings = {},
|
||||
onDiagnosticsChanged = {},
|
||||
onBugWhatChanged = {},
|
||||
onBugExpectedChanged = {},
|
||||
onBugStepsChanged = {},
|
||||
onBugContactChanged = {},
|
||||
onBugIncludeLogsChanged = {},
|
||||
onSubmitBugReport = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +147,13 @@ class FoundationComposeTest {
|
||||
onResetFolder = {},
|
||||
onNotificationsChanged = {},
|
||||
onOpenNotificationSettings = { opened = true },
|
||||
onDiagnosticsChanged = {},
|
||||
onBugWhatChanged = {},
|
||||
onBugExpectedChanged = {},
|
||||
onBugStepsChanged = {},
|
||||
onBugContactChanged = {},
|
||||
onBugIncludeLogsChanged = {},
|
||||
onSubmitBugReport = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user