Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/client-ids-userid-datadogid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ledgerhq/client-ids": minor
---

Add UserId and DatadogId ID classes with redaction and export-rules. Extend identities store with userId/datadogId; add initFromPersisted, importFromLegacy, initFromScratch. Persistence and sync middleware use identities state. Trim DeviceId persistence allowlist.
5 changes: 5 additions & 0 deletions .changeset/desktop-identities-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ledger-live-desktop": minor
---

Migrate unified identities from legacy storage. Set Sentry user id from identities datadogId. Fix crash screen when outside Redux: omit userId in export logs and conditionally render Hard Reset block only when store is available.
5 changes: 5 additions & 0 deletions .changeset/neat-buttons-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ledger-live-desktop": minor
---

use datadogId from identities in Sentry and improve anonymizer
5 changes: 5 additions & 0 deletions .changeset/two-knives-act.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ledger-live-desktop": minor
---

add Datadog RUM in desktop app
13 changes: 13 additions & 0 deletions .cursor/rules/client-ids.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ alwaysApply: false

# Client IDs Library (`@ledgerhq/client-ids`)

## Privacy & Security β€” `@ledgerhq/client-ids`

Sensitive identifiers (DeviceId, UserId, DatadogId) must always use the `@ledgerhq/client-ids` library:

- **Never** use raw string IDs for devices, users, or analytics.
- **Always** use `DeviceId`, `UserId`, or `DatadogId` classes from `@ledgerhq/client-ids/ids`.
- ID values are only accessible through explicit export methods (e.g., `exportUserIdForSomething()`).
- Every export method must be allowlisted in `libs/client-ids/export-rules.json` with a justification.
- Export IDs only at system boundaries (API calls, persistence) β€” never in the middle of processing.
- `toString()` and `toJSON()` return `[DeviceId:REDACTED]` by default β€” this is by design.

---

## Purpose

The `client-ids` library provides **unified, privacy-protected ID management** for Ledger Live. It isolates sensitive identifiers (DeviceId, UserId, DatadogId) and prevents accidental exposure through logging or serialization.
Expand Down
11 changes: 11 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ This is the **ledger-live** monorepo β€” a pnpm + turborepo workspace containing

Every PR that changes user-facing behavior or library APIs must include a changeset (`pnpm changeset`). Flag PRs that add features or fix bugs without one.

## Privacy & Security β€” `@ledgerhq/client-ids`

Sensitive identifiers (DeviceId, UserId, DatadogId) must always use the `@ledgerhq/client-ids` library:

- **Never** use raw string IDs for devices, users, or analytics.
- **Always** use `DeviceId`, `UserId`, or `DatadogId` classes from `@ledgerhq/client-ids/ids`.
- ID values are only accessible through explicit export methods (e.g., `exportUserIdForSomething()`).
- Every export method must be allowlisted in `libs/client-ids/export-rules.json` with a justification.
- Export IDs only at system boundaries (API calls, persistence) β€” never in the middle of processing.
- `toString()` and `toJSON()` return `[DeviceId:REDACTED]` by default β€” this is by design.

## Dependency Review

When a PR adds or updates dependencies in any `package.json`:
Expand Down
8 changes: 7 additions & 1 deletion apps/ledger-live-desktop/.env.production
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,10 @@ FIREBASE_MESSAGING_SENDER_ID="212042068804"
FIREBASE_APP_ID="1:212042068804:web:268d6f11671689c0b51d11"
BRAZE_API_KEY="d3af8483-c4ea-4325-b9fa-619038e98b99"
BRAZE_CUSTOM_ENDPOINT="sdk.fra-02.braze.eu"
SEGMENT_WRITE_KEY="RduLzmQ1vSH5aeNrSDKWimFrivogTANI"
SEGMENT_WRITE_KEY="RduLzmQ1vSH5aeNrSDKWimFrivogTANI"

# Datadog RUM (production)
DATADOG_APPLICATION_ID="e269c93d-397f-4eae-aeb5-82b061018cd2"
DATADOG_CLIENT_TOKEN="pub46b7d0392f192ba25ca6a00ad486362f"
DATADOG_SITE="datadoghq.eu"
DATADOG_ENV="production"
8 changes: 7 additions & 1 deletion apps/ledger-live-desktop/.env.staging
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,10 @@ FIREBASE_MESSAGING_SENDER_ID="1008987457941"
FIREBASE_APP_ID="1:1008987457941:web:14f6fbee631e0438d6ce9c"
BRAZE_API_KEY="25715c7f-d18e-4ef2-9b09-cc5ad10aa514"
BRAZE_CUSTOM_ENDPOINT="sdk.fra-02.braze.eu"
SEGMENT_WRITE_KEY="olBQc203GA3fXVa48rJB9c3826CY1axp"
SEGMENT_WRITE_KEY="olBQc203GA3fXVa48rJB9c3826CY1axp"

# Datadog RUM (staging)
DATADOG_APPLICATION_ID="afa5d47a-0092-4b07-bcfa-072040faba3e"
DATADOG_CLIENT_TOKEN="pub8f96d7b432f3ef1cc6db970ca4b9a3ee"
DATADOG_SITE="datadoghq.eu"
DATADOG_ENV="staging"
4 changes: 4 additions & 0 deletions apps/ledger-live-desktop/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ module.exports = {
Atomics: "readonly",
SharedArrayBuffer: "readonly",
__SENTRY_URL__: "readonly",
__DATADOG_APPLICATION_ID__: "readonly",
__DATADOG_CLIENT_TOKEN__: "readonly",
__DATADOG_SITE__: "readonly",
__DATADOG_ENV__: "readonly",
__APP_VERSION__: "readonly",
__GIT_REVISION__: "readonly",
__PRERELEASE__: "readonly",
Expand Down
4 changes: 4 additions & 0 deletions apps/ledger-live-desktop/index-types.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
declare const INDEX_URL: string;
declare const __SENTRY_URL__: string;
declare const __DATADOG_APPLICATION_ID__: string | null;
declare const __DATADOG_CLIENT_TOKEN__: string | null;
declare const __DATADOG_SITE__: string | null;
declare const __DATADOG_ENV__: string | null;
declare const __APP_VERSION__: string;
declare const __GIT_REVISION__: string;
declare const __PRERELEASE__: string;
Expand Down
4 changes: 4 additions & 0 deletions apps/ledger-live-desktop/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ const commonConfig = {
__SENTRY_URL__: null,
__PRERELEASE__: "null",
__CHANNEL__: "null",
__DATADOG_APPLICATION_ID__: null,
__DATADOG_CLIENT_TOKEN__: null,
__DATADOG_SITE__: null,
__DATADOG_ENV__: null,
},
moduleNameMapper,
testPathIgnorePatterns,
Expand Down
1 change: 1 addition & 0 deletions apps/ledger-live-desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"test-deep-links": "ws --spa ledger-live-desktop-deeplinks.html"
},
"dependencies": {
"@datadog/browser-rum": "6.28.1",
"@braze/web-sdk": "6.4.0",
"@electron/fuses": "2.0.0",
"@features/market-banner": "workspace:^",
Expand Down
96 changes: 96 additions & 0 deletions apps/ledger-live-desktop/src/datadog/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { buildBeforeSend, getDatadogBuildConfig } from "./config";

jest.mock("~/sentry/anonymizer", () => ({
__esModule: true,
default: {
filepathRecursiveReplacer: jest.fn((obj: Record<string, unknown>) => {
obj._anonymized = true;
}),
},
}));

jest.mock("./ignoreErrors", () => ({
shouldIgnoreErrorMessage: jest.fn((msg: string) => msg.includes("IGNORE_ME")),
}));

describe("datadog config", () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe("getDatadogBuildConfig", () => {
it("should return config from build globals (null in Jest)", () => {
const result = getDatadogBuildConfig();
expect(result).toEqual({
applicationId: null,
clientToken: null,
site: null,
env: null,
});
});
});

describe("buildBeforeSend", () => {
it("should return false when shouldSend returns false", () => {
const shouldSend = jest.fn().mockReturnValue(false);
const beforeSend = buildBeforeSend(shouldSend);
expect(beforeSend({}, undefined)).toBe(false);
expect(shouldSend).toHaveBeenCalledTimes(1);
});

it("should return true for non-object event", () => {
const shouldSend = jest.fn().mockReturnValue(true);
const beforeSend = buildBeforeSend(shouldSend);
expect(beforeSend(null, undefined)).toBe(true);
expect(beforeSend("string", undefined)).toBe(true);
});

it("should return false when error message matches ignore list", () => {
const shouldSend = jest.fn().mockReturnValue(true);
const beforeSend = buildBeforeSend(shouldSend);
const event = { error: { message: "IGNORE_ME please" } };
expect(beforeSend(event, undefined)).toBe(false);
});

it("should return false when event.message matches ignore list", () => {
const shouldSend = jest.fn().mockReturnValue(true);
const beforeSend = buildBeforeSend(shouldSend);
expect(beforeSend({ message: "IGNORE_ME" }, undefined)).toBe(false);
});

it("should remove server_name from event", () => {
const shouldSend = jest.fn().mockReturnValue(true);
const beforeSend = buildBeforeSend(shouldSend);
const event = { server_name: "my-machine", message: "ok" };
expect(beforeSend(event, undefined)).toBe(true);
expect(event).not.toHaveProperty("server_name");
});

it("should call anonymizer and return true for sendable event", () => {
const anonymizer = jest.requireMock("~/sentry/anonymizer").default;
const shouldSend = jest.fn().mockReturnValue(true);
const beforeSend = buildBeforeSend(shouldSend);
const event: Record<string, unknown> = { message: "Some error" };
expect(beforeSend(event, undefined)).toBe(true);
expect(anonymizer.filepathRecursiveReplacer).toHaveBeenCalledWith(event);
expect(event._anonymized).toBe(true);
});

it("should return true when anonymizer throws (logs and continues)", () => {
const anonymizer = jest.requireMock("~/sentry/anonymizer").default;
jest.mocked(anonymizer.filepathRecursiveReplacer).mockImplementationOnce(() => {
throw new Error("anonymizer failed");
});
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
const shouldSend = jest.fn().mockReturnValue(true);
const beforeSend = buildBeforeSend(shouldSend);
const event = { message: "ok" };
expect(beforeSend(event, undefined)).toBe(true);
expect(consoleSpy).toHaveBeenCalledWith(
"Datadog beforeSend: anonymization failed",
expect.any(Error),
);
consoleSpy.mockRestore();
});
});
});
53 changes: 53 additions & 0 deletions apps/ledger-live-desktop/src/datadog/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import anonymizer from "~/sentry/anonymizer";
import { shouldIgnoreErrorMessage } from "./ignoreErrors";

export type ShouldSendCallback = () => boolean;

/**
* Builds the beforeSend callback for Datadog RUM.
* Drops events when opt-in is off or error message matches ignore list;
* applies anonymization to the payload (parity with Sentry).
*/
export function buildBeforeSend(shouldSend: ShouldSendCallback) {
return (event: unknown, _context?: unknown): boolean => {
if (!shouldSend()) return false;
if (typeof event !== "object" || event === null) return true;

const ev = event as Record<string, unknown>;

// Error events (event.type === 'error'): drop if message matches ignore list
let message = "";
if (ev.error && typeof ev.error === "object") {
const errObj = ev.error as Record<string, unknown>;
if (typeof errObj.message === "string") message = errObj.message;
}
if (!message && typeof ev.message === "string") message = ev.message;
if (message && shouldIgnoreErrorMessage(message)) return false;

// Remove server_name (machine name)
if ("server_name" in ev) delete ev.server_name;

// Anonymize file paths in payload (parity with Sentry)
try {
anonymizer.filepathRecursiveReplacer(ev);
} catch (e) {
console.error("Datadog beforeSend: anonymization failed", e);
}

return true;
};
}

export function getDatadogBuildConfig(): {
applicationId: string | null | undefined;
clientToken: string | null | undefined;
site: string | null | undefined;
env: string | null | undefined;
} {
return {
applicationId: __DATADOG_APPLICATION_ID__,
clientToken: __DATADOG_CLIENT_TOKEN__,
site: __DATADOG_SITE__,
env: __DATADOG_ENV__,
};
}
37 changes: 37 additions & 0 deletions apps/ledger-live-desktop/src/datadog/ignoreErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { shouldIgnoreErrorMessage, IGNORE_ERROR_MESSAGES } from "./ignoreErrors";

describe("datadog ignoreErrors", () => {
describe("shouldIgnoreErrorMessage", () => {
it("should return false for message not matching any pattern", () => {
expect(shouldIgnoreErrorMessage("Some random error")).toBe(false);
expect(shouldIgnoreErrorMessage("UnknownError")).toBe(false);
});

it("should return true when message includes a string pattern", () => {
expect(shouldIgnoreErrorMessage("API HTTP 500")).toBe(true);
expect(shouldIgnoreErrorMessage("ECONNREFUSED connection failed")).toBe(true);
expect(shouldIgnoreErrorMessage("Network Error")).toBe(true);
expect(shouldIgnoreErrorMessage("Failed to fetch")).toBe(true);
expect(shouldIgnoreErrorMessage("request timed out")).toBe(true);
expect(shouldIgnoreErrorMessage("UserRefusedOnDevice")).toBe(true);
});

it("should return true when message matches RegExp pattern", () => {
// IGNORE_ERROR_MESSAGES uses string includes; check one more
expect(shouldIgnoreErrorMessage("status code 404")).toBe(true);
});

it("should return false for empty string", () => {
expect(shouldIgnoreErrorMessage("")).toBe(false);
});
});

describe("IGNORE_ERROR_MESSAGES", () => {
it("should contain expected patterns", () => {
expect(IGNORE_ERROR_MESSAGES).toContain("API HTTP");
expect(IGNORE_ERROR_MESSAGES).toContain("ECONNREFUSED");
expect(IGNORE_ERROR_MESSAGES).toContain("Failed to fetch");
expect(IGNORE_ERROR_MESSAGES).toContain("UserRefusedOnDevice");
});
});
});
Loading
Loading