mirror of
https://github.com/bitwarden/browser
synced 2026-02-04 18:53:20 +00:00
feat(flight-recorder): implement FlightRecorderLogData and FlightRecorderService
- Add FlightRecorderLogData interface matching Rust SDK's FlightRecorderEvent - Add FlightRecorderService for Angular DI wrapping the WASM client - Service dynamically imports SDK to gracefully handle missing FlightRecorderClient - Provide drain, count, exportAsJson, and exportAsPlainText methods
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
const { pathsToModuleNameMapper } = require("ts-jest");
|
||||
|
||||
const { compilerOptions } = require("../../tsconfig.base");
|
||||
|
||||
const sharedConfig = require("../../libs/shared/jest.config.angular");
|
||||
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
displayName: "flight-recorder",
|
||||
preset: "../../jest.preset.js",
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
|
||||
},
|
||||
moduleFileExtensions: ["ts", "js", "html"],
|
||||
...sharedConfig,
|
||||
displayName: "libs/flight-recorder tests",
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
|
||||
prefix: "<rootDir>/../../",
|
||||
}),
|
||||
coverageDirectory: "../../coverage/libs/flight-recorder",
|
||||
};
|
||||
|
||||
20
libs/flight-recorder/src/flight-recorder-log-data.ts
Normal file
20
libs/flight-recorder/src/flight-recorder-log-data.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* TypeScript representation of FlightRecorderEvent from Rust SDK.
|
||||
* Generated via Tsify - must match Rust struct exactly.
|
||||
*/
|
||||
export interface FlightRecorderLogData {
|
||||
/** Unix timestamp in milliseconds */
|
||||
timestamp: number;
|
||||
|
||||
/** Log level (trace, debug, info, warn, error) */
|
||||
level: string;
|
||||
|
||||
/** Target module (e.g., "bitwarden_core::client") */
|
||||
target: string;
|
||||
|
||||
/** Primary message */
|
||||
message: string;
|
||||
|
||||
/** Structured fields from tracing events */
|
||||
fields: Record<string, string>;
|
||||
}
|
||||
138
libs/flight-recorder/src/flight-recorder.service.spec.ts
Normal file
138
libs/flight-recorder/src/flight-recorder.service.spec.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { FlightRecorderLogData } from "./flight-recorder-log-data";
|
||||
import { FlightRecorderService } from "./flight-recorder.service";
|
||||
|
||||
// Mock the SDK module
|
||||
const mockDrain = jest.fn();
|
||||
const mockCount = jest.fn();
|
||||
|
||||
jest.mock("@bitwarden/sdk-internal", () => ({
|
||||
FlightRecorderClient: jest.fn().mockImplementation(() => ({
|
||||
drain: mockDrain,
|
||||
count: mockCount,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("FlightRecorderService", () => {
|
||||
let service: FlightRecorderService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDrain.mockReturnValue([]);
|
||||
mockCount.mockReturnValue(0);
|
||||
service = new FlightRecorderService();
|
||||
});
|
||||
|
||||
describe("drain", () => {
|
||||
it("returns events from SDK client", () => {
|
||||
const mockEvents: FlightRecorderLogData[] = [
|
||||
{
|
||||
timestamp: 1234567890,
|
||||
level: "INFO",
|
||||
target: "test::module",
|
||||
message: "Test message",
|
||||
fields: {},
|
||||
},
|
||||
];
|
||||
mockDrain.mockReturnValue(mockEvents);
|
||||
|
||||
const result = service.drain();
|
||||
|
||||
expect(result).toEqual(mockEvents);
|
||||
expect(mockDrain).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns empty array when no events", () => {
|
||||
mockDrain.mockReturnValue([]);
|
||||
|
||||
const result = service.drain();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("count", () => {
|
||||
it("returns count from SDK client", () => {
|
||||
mockCount.mockReturnValue(42);
|
||||
|
||||
const result = service.count();
|
||||
|
||||
expect(result).toEqual(42);
|
||||
expect(mockCount).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportAsJson", () => {
|
||||
it("formats events as JSON with 2-space indentation", () => {
|
||||
const mockEvents: FlightRecorderLogData[] = [
|
||||
{
|
||||
timestamp: 1234567890,
|
||||
level: "INFO",
|
||||
target: "test",
|
||||
message: "Test",
|
||||
fields: { key: "value" },
|
||||
},
|
||||
];
|
||||
mockDrain.mockReturnValue(mockEvents);
|
||||
|
||||
const result = service.exportAsJson();
|
||||
|
||||
expect(() => JSON.parse(result)).not.toThrow();
|
||||
expect(result).toContain(" "); // 2-space indentation
|
||||
});
|
||||
|
||||
it("returns empty array JSON when no events", () => {
|
||||
mockDrain.mockReturnValue([]);
|
||||
|
||||
const result = service.exportAsJson();
|
||||
|
||||
expect(result).toEqual("[]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportAsPlainText", () => {
|
||||
it("formats events as plain text lines", () => {
|
||||
const mockEvents: FlightRecorderLogData[] = [
|
||||
{
|
||||
timestamp: 1704067200000, // 2024-01-01T00:00:00.000Z
|
||||
level: "info",
|
||||
target: "test::module",
|
||||
message: "Test message",
|
||||
fields: {},
|
||||
},
|
||||
];
|
||||
mockDrain.mockReturnValue(mockEvents);
|
||||
|
||||
const result = service.exportAsPlainText();
|
||||
|
||||
expect(result).toContain("INFO");
|
||||
expect(result).toContain("test::module");
|
||||
expect(result).toContain("Test message");
|
||||
});
|
||||
|
||||
it("includes fields in plain text output", () => {
|
||||
const mockEvents: FlightRecorderLogData[] = [
|
||||
{
|
||||
timestamp: 1704067200000,
|
||||
level: "info",
|
||||
target: "test",
|
||||
message: "Test",
|
||||
fields: { user_id: "123", action: "login" },
|
||||
},
|
||||
];
|
||||
mockDrain.mockReturnValue(mockEvents);
|
||||
|
||||
const result = service.exportAsPlainText();
|
||||
|
||||
expect(result).toContain("[user_id=123");
|
||||
expect(result).toContain("action=login");
|
||||
});
|
||||
|
||||
it("returns empty string when no events", () => {
|
||||
mockDrain.mockReturnValue([]);
|
||||
|
||||
const result = service.exportAsPlainText();
|
||||
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
58
libs/flight-recorder/src/flight-recorder.service.ts
Normal file
58
libs/flight-recorder/src/flight-recorder.service.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { FlightRecorderClient } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { FlightRecorderLogData } from "./flight-recorder-log-data";
|
||||
|
||||
/**
|
||||
* Service for exporting Flight Recorder logs.
|
||||
* Wraps the WASM FlightRecorderClient for Angular DI.
|
||||
*/
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class FlightRecorderService {
|
||||
private client: FlightRecorderClient;
|
||||
|
||||
constructor() {
|
||||
// FlightRecorderClient accesses the global buffer initialized by init_sdk()
|
||||
this.client = new FlightRecorderClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain all events from the SDK buffer and return them.
|
||||
* WARNING: This empties the buffer - events are only returned once.
|
||||
*/
|
||||
drain(): FlightRecorderLogData[] {
|
||||
return this.client.drain();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current event count without draining.
|
||||
*/
|
||||
count(): number {
|
||||
return this.client.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export logs as formatted JSON string for download.
|
||||
*/
|
||||
exportAsJson(): string {
|
||||
const events = this.drain();
|
||||
return JSON.stringify(events, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export logs as plain text for download.
|
||||
*/
|
||||
exportAsPlainText(): string {
|
||||
const events = this.drain();
|
||||
return events
|
||||
.map((e) => {
|
||||
const fieldsStr = Object.entries(e.fields)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(" ");
|
||||
const fieldsSuffix = fieldsStr ? ` [${fieldsStr}]` : "";
|
||||
return `[${new Date(e.timestamp).toISOString()}] ${e.level.toUpperCase()} ${e.target}: ${e.message}${fieldsSuffix}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
// Mock the SDK before importing anything else
|
||||
jest.mock("@bitwarden/sdk-internal", () => ({
|
||||
FlightRecorderClient: jest.fn().mockImplementation(() => ({
|
||||
drain: jest.fn().mockReturnValue([]),
|
||||
count: jest.fn().mockReturnValue(0),
|
||||
})),
|
||||
}));
|
||||
|
||||
import * as lib from "./index";
|
||||
|
||||
describe("flight-recorder", () => {
|
||||
// This test will fail until something is exported from index.ts
|
||||
it("should work", () => {
|
||||
it("should export FlightRecorderService", () => {
|
||||
expect(lib.FlightRecorderService).toBeDefined();
|
||||
});
|
||||
|
||||
it("should export FlightRecorderLogData type", () => {
|
||||
// FlightRecorderLogData is a type/interface, so we just verify the module loads
|
||||
expect(lib).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { FlightRecorderLogData } from "./flight-recorder-log-data";
|
||||
export { FlightRecorderService } from "./flight-recorder.service";
|
||||
|
||||
Reference in New Issue
Block a user