Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as Cause from "effect/Cause";
import * as Data from "effect/Data";
import * as Duration from "effect/Duration";
import { randomUUIDv4 } from "@threadlines/shared/uuid";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
Expand Down Expand Up @@ -39,6 +40,20 @@ class DesktopDevelopmentBackendPortRequiredError extends Data.TaggedError(
}
}

// Port discovery runs before the backend manager exists, so a probe that a
// security product leaves hanging would wedge startup with no window and no
// recovery prompt. The deadline turns that into the visible fatal-startup
// error box instead.
const BACKEND_PORT_DISCOVERY_TIMEOUT = Duration.seconds(30);

class DesktopBackendPortDiscoveryTimeoutError extends Data.TaggedError(
"DesktopBackendPortDiscoveryTimeoutError",
)<{}> {
override get message() {
return `Timed out selecting a local port for the Threadlines background service after ${Duration.toSeconds(BACKEND_PORT_DISCOVERY_TIMEOUT)}s. A firewall or security tool may be blocking local network probes.`;
}
}

const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } =
DesktopObservability.makeComponentLogger("desktop-bootstrap");

Expand Down Expand Up @@ -99,7 +114,12 @@ const bootstrap = Effect.gen(function* () {
const backendPortSelection = yield* resolveDesktopBackendPort({
configuredPort: environment.configuredBackendPort,
probeHosts,
});
}).pipe(
Effect.timeoutOrElse({
duration: BACKEND_PORT_DISCOVERY_TIMEOUT,
orElse: () => new DesktopBackendPortDiscoveryTimeoutError(),
}),
);
const backendPort = backendPortSelection.port;
yield* logBootstrapInfo(
backendPortSelection.selectedByScan
Expand Down
103 changes: 103 additions & 0 deletions apps/desktop/src/app/DesktopCrashReport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { assert, describe, it } from "@effect/vitest";

import {
redactSecrets,
resolveTelemetryConsent,
scrubUserPaths,
truncateTail,
} from "./DesktopCrashReport.ts";

describe("scrubUserPaths", () => {
it("replaces the home directory in both separator styles and any casing", () => {
const text =
"Error: EACCES at C:\\Users\\wilfredo\\.threadlines\\userdata\\state.sqlite " +
"(also seen as c:/users/wilfredo/.threadlines/logs)";
const scrubbed = scrubUserPaths(text, "C:\\Users\\wilfredo");
assert.equal(
scrubbed,
"Error: EACCES at ~\\.threadlines\\userdata\\state.sqlite (also seen as ~/.threadlines/logs)",
);
});

it("handles posix home directories and leaves unrelated text alone", () => {
const scrubbed = scrubUserPaths("failed at /Users/will/x; port 3773 busy", "/Users/will");
assert.equal(scrubbed, "failed at ~/x; port 3773 busy");
});

it("is a no-op for an empty home directory", () => {
assert.equal(scrubUserPaths("text", ""), "text");
});
});

describe("redactSecrets", () => {
it("cuts values after credential-shaped labels", () => {
assert.equal(
redactSecrets("Error: connect failed api_key=sk-live-123 token: abc.def password=hunter2"),
"Error: connect failed api_key=[redacted] token: [redacted] password=[redacted]",
);
});

it("leaves ordinary error text alone", () => {
const text = "EADDRINUSE: address already in use 127.0.0.1:3773";
assert.equal(redactSecrets(text), text);
});
});

describe("truncateTail", () => {
it("keeps the end of oversized output, where the fatal error lands", () => {
assert.equal(truncateTail("abcdef", 4), "cdef");
assert.equal(truncateTail("abc", 4), "abc");
});
});

describe("resolveTelemetryConsent", () => {
it("defaults to enabled without settings", () => {
assert.isTrue(resolveTelemetryConsent({ envOverride: undefined, rawSettingsJson: undefined }));
});

it("honors usageAnalyticsEnabled from settings", () => {
assert.isFalse(
resolveTelemetryConsent({
envOverride: undefined,
rawSettingsJson: JSON.stringify({ usageAnalyticsEnabled: false }),
}),
);
assert.isTrue(
resolveTelemetryConsent({
envOverride: undefined,
rawSettingsJson: JSON.stringify({ usageAnalyticsEnabled: true }),
}),
);
});

it("lets the env override win in both directions", () => {
assert.isFalse(
resolveTelemetryConsent({
envOverride: "false",
rawSettingsJson: JSON.stringify({ usageAnalyticsEnabled: true }),
}),
);
assert.isTrue(
resolveTelemetryConsent({
envOverride: "true",
rawSettingsJson: JSON.stringify({ usageAnalyticsEnabled: false }),
}),
);
});

it("reads JSONC settings the way the server does", () => {
assert.isFalse(
resolveTelemetryConsent({
envOverride: undefined,
rawSettingsJson: `{
// telemetry disabled by hand
"usageAnalyticsEnabled": false,
}`,
}),
);
});

it("keeps the default when settings are unreadable", () => {
assert.isTrue(resolveTelemetryConsent({ envOverride: undefined, rawSettingsJson: "not json" }));
});
});
213 changes: 213 additions & 0 deletions apps/desktop/src/app/DesktopCrashReport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/**
* DesktopCrashReport - anonymous startup-failure telemetry for the desktop
* shell.
*
* The server owns regular usage analytics, but when the backend never boots
* there is no server to report anything — so the shell sends one sanitized
* event itself. It honors the same consent as the server (`settings.json`
* `usageAnalyticsEnabled`, `THREADLINES_TELEMETRY_ENABLED` override), reuses
* the same anonymous install id file, and never sends raw paths: everything
* under the user's home directory is scrubbed to `~` before leaving the
* machine.
*/

import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
import * as Crypto from "node:crypto";

import { DEFAULT_SERVER_SETTINGS, ServerSettings } from "@threadlines/contracts";
import { fromLenientJson } from "@threadlines/shared/schemaJson";

import * as DesktopEnvironment from "./DesktopEnvironment.ts";

declare const __THREADLINES_BUNDLED_POSTHOG_KEY__: string | undefined;
declare const __THREADLINES_BUNDLED_POSTHOG_HOST__: string | undefined;

const bundledPosthogKey =
typeof __THREADLINES_BUNDLED_POSTHOG_KEY__ === "string"
? __THREADLINES_BUNDLED_POSTHOG_KEY__
: "";

const bundledPosthogHost =
typeof __THREADLINES_BUNDLED_POSTHOG_HOST__ === "string"
? __THREADLINES_BUNDLED_POSTHOG_HOST__
: "https://us.i.posthog.com";

const ANONYMOUS_ID_FILE_NAME = "anonymous-id";
const STDERR_TAIL_MAX_CHARS = 2_000;

// Values following credential-shaped labels are cut before anything leaves
// the machine. Boot-phase stderr should never contain these, but a config
// echo or connection string in a crash must not end up in telemetry.
const SECRET_PATTERN =
/((?:key|token|secret|password|passwd|credential|bearer|authorization)[\w-]*\s*[=:]\s*)[^\s"']+/gi;

export function redactSecrets(text: string): string {
return text.replace(SECRET_PATTERN, "$1[redacted]");
}

export interface DesktopStartupFailureReport {
readonly failureKind: "process-exit" | "readiness-timeout";
readonly attempts: number;
readonly lastExitCode: Option.Option<number>;
readonly lastReason: string;
readonly stderrTail: string;
}

export interface DesktopCrashReportShape {
/** Best-effort: resolves void on success and on any failure alike. */
readonly reportStartupFailure: (report: DesktopStartupFailureReport) => Effect.Effect<void>;
}

export class DesktopCrashReport extends Context.Service<
DesktopCrashReport,
DesktopCrashReportShape
>()("threadlines/desktop/CrashReport") {}

/**
* Replaces every occurrence of the user's home directory (either separator
* style, any casing) with `~` so crash reports carry no usernames or absolute
* personal paths.
*/
export function scrubUserPaths(text: string, homeDirectory: string): string {
if (homeDirectory.length === 0) {
return text;
}
const variants = new Set([
homeDirectory,
homeDirectory.replaceAll("\\", "/"),
homeDirectory.replaceAll("/", "\\"),
]);
let scrubbed = text;
for (const variant of variants) {
const escaped = variant.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
scrubbed = scrubbed.replace(new RegExp(escaped, "gi"), "~");
}
return scrubbed;
}

/** Keeps the end of the captured stderr, where the fatal error lands. */
export function truncateTail(text: string, maxChars: number = STDERR_TAIL_MAX_CHARS): string {
return text.length <= maxChars ? text : text.slice(text.length - maxChars);
}

const ServerSettingsJson = fromLenientJson(ServerSettings);
const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson);

/**
* Mirrors the server's telemetry consent: `THREADLINES_TELEMETRY_ENABLED`
* wins when set, otherwise `usageAnalyticsEnabled` from settings.json decoded
* with the same lenient JSONC parser the server uses, defaulting to enabled
* when the file is absent or undecodable.
*/
export function resolveTelemetryConsent(input: {
readonly envOverride: string | undefined;
readonly rawSettingsJson: string | undefined;
}): boolean {
const override = input.envOverride?.trim().toLowerCase();
if (override === "false") return false;
if (override === "true") return true;

if (input.rawSettingsJson === undefined) {
return DEFAULT_SERVER_SETTINGS.usageAnalyticsEnabled;
}
return Option.match(decodeServerSettingsJson(input.rawSettingsJson), {
onNone: () => DEFAULT_SERVER_SETTINGS.usageAnalyticsEnabled,
onSome: (settings) => settings.usageAnalyticsEnabled,
});
}

const hashIdentifier = (value: string): string =>
Crypto.createHash("sha256").update(value).digest("hex");

const makeDesktopCrashReport = Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const fileSystem = yield* FileSystem.FileSystem;
const httpClient = yield* HttpClient.HttpClient;
const anonymousIdPath = environment.path.join(environment.stateDir, ANONYMOUS_ID_FILE_NAME);

// Same file the server uses, so the crash report and later usage telemetry
// count as one install. Created here when the backend never got far enough
// to create it itself. Like the server, sending is skipped when the id
// cannot be persisted: an unpersisted id would make every report look like
// a fresh installation.
const getIdentifier = Effect.gen(function* () {
const existing = yield* fileSystem
.readFileString(anonymousIdPath)
.pipe(Effect.map(Option.some), Effect.orElseSucceed(Option.none<string>));
if (Option.isSome(existing)) {
return Option.some(hashIdentifier(existing.value));
}
const generated = Crypto.randomUUID();
const persisted = yield* fileSystem.writeFileString(anonymousIdPath, generated).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
);
return persisted ? Option.some(hashIdentifier(generated)) : Option.none<string>();
});

const reportStartupFailure: DesktopCrashReportShape["reportStartupFailure"] = (report) =>
Effect.gen(function* () {
// Same env overrides the server's AnalyticsService honors.
const posthogKey = process.env.THREADLINES_POSTHOG_KEY?.trim() || bundledPosthogKey.trim();
const posthogHost = process.env.THREADLINES_POSTHOG_HOST?.trim() || bundledPosthogHost;
if (!posthogKey) return;

const rawSettingsJson = yield* fileSystem
.readFileString(environment.serverSettingsPath)
.pipe(Effect.map(Option.some), Effect.orElseSucceed(Option.none<string>));
const consented = resolveTelemetryConsent({
envOverride: process.env.THREADLINES_TELEMETRY_ENABLED,
rawSettingsJson: Option.getOrUndefined(rawSettingsJson),
});
if (!consented) return;

const identifier = yield* getIdentifier;
if (Option.isNone(identifier)) return;
const scrub = (text: string) =>
redactSecrets(scrubUserPaths(text, environment.homeDirectory));
const payload = {
api_key: posthogKey,
batch: [
{
event: "desktop.backend.startup_failed",
distinct_id: identifier.value,
properties: {
$process_person_profile: false,
platform: environment.platform,
arch: environment.processArch,
threadlinesVersion: environment.appVersion,
clientType: "desktop-app",
failureKind: report.failureKind,
attempts: report.attempts,
exitCode: Option.getOrNull(report.lastExitCode),
reason: scrub(report.lastReason),
stderrTail: scrub(truncateTail(report.stderrTail)),
},
timestamp: new Date().toISOString(),
},
],
};

yield* HttpClientRequest.post(`${posthogHost}/batch/`).pipe(
HttpClientRequest.bodyJson(payload),
Effect.flatMap(httpClient.execute),
Effect.flatMap(HttpClientResponse.filterStatusOk),
Effect.asVoid,
);
}).pipe(
Effect.catch((error) =>
Effect.logDebug("startup failure crash report not sent", { cause: error }),
),
Effect.withSpan("desktop.crashReport.reportStartupFailure"),
);

return DesktopCrashReport.of({ reportStartupFailure });
});

export const layer = Layer.effect(DesktopCrashReport, makeDesktopCrashReport);
Loading
Loading