@ogcio/o11y-sdk-react 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,78 @@
1
+ import { faro } from "@grafana/faro-web-sdk";
2
+
3
+ // Generous IP address matchers (might match some invalid addresses like 192.168.01.1)
4
+ const IPV4_REGEX =
5
+ /(?<!\d)(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}(?!\d)/gi;
6
+ const IPV6_REGEX =
7
+ /(?<![0-9a-f:])((?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|:(?::[0-9A-Fa-f]{1,4}){1,7}|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?::[0-9A-Fa-f]{1,4}){1,6}|:(?::[0-9A-Fa-f]{1,4}){1,7}:?|(?:[0-9A-Fa-f]{1,4}:){1,4}:(?:25[0-5]|2[0-4]\d|1\d\d|\d{1,2})(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|\d{1,2})){3})(?![0-9a-f:])/gi;
8
+
9
+ /**
10
+ * Redacts all ip addresses in the input string and collects metadata.
11
+ *
12
+ * @param {string} value The input string potentially containing ip addresses.
13
+ * @returns {{
14
+ * redacted: string,
15
+ * count: number,
16
+ * domains: Record<string, number>
17
+ * }}
18
+ *
19
+ * An object containing:
20
+ * - `redacted`: the string with IP addresses replaced by `[REDACTED IPV*]`
21
+ * - `counters`: total number of addresses redacted by IPv* type
22
+ * - `domains`: a map of domain names to the number of times they were redacted
23
+ */
24
+ function _redactIps(value: string): {
25
+ redacted: string;
26
+ counters: Record<string, number>;
27
+ } {
28
+ const counters: Record<string, number> = {};
29
+ const redacted = value
30
+ .replace(IPV4_REGEX, () => {
31
+ counters["IPv4"] = (counters["IPv4"] || 0) + 1;
32
+ return "[REDACTED IPV4]";
33
+ })
34
+ .replace(IPV6_REGEX, () => {
35
+ counters["IPv4"] = (counters["IPv4"] || 0) + 1;
36
+ return "[REDACTED IPV6]";
37
+ });
38
+ return { redacted, counters };
39
+ }
40
+
41
+ /**
42
+ * Redacts provided input and collects metadata metrics about redacted IPs,
43
+ * data source and kind.
44
+ *
45
+ * @param {string} value The input string potentially containing IP addresses.
46
+ * @param {string} source The source of the attribute being redacted (log, span, metric).
47
+ * @param {string} kind The type of the data structure containing the PII
48
+ * @returns {string} the redacted value
49
+ */
50
+ export const ipRedactor = (
51
+ value: string,
52
+ source: string,
53
+ kind: string,
54
+ ): string => {
55
+ const { redacted, counters } = _redactIps(value);
56
+
57
+ Object.entries(counters).forEach(([type, counter]) => {
58
+ if (counter > 0) {
59
+ faro.api.pushMeasurement(
60
+ {
61
+ type: "faro_o11y_pii_redaction",
62
+ values: {
63
+ redacted: counter,
64
+ },
65
+ },
66
+ {
67
+ context: {
68
+ pii_type: type,
69
+ redaction_source: source,
70
+ pii_format: kind,
71
+ },
72
+ },
73
+ );
74
+ }
75
+ });
76
+
77
+ return redacted;
78
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogcio/o11y-sdk-react",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Opentelemetry standard instrumentation SDK for React based project",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -19,19 +19,19 @@
19
19
  "author": "team:ogcio/observability",
20
20
  "license": "ISC",
21
21
  "dependencies": {
22
- "@grafana/faro-react": "^1.18.2",
23
- "@grafana/faro-web-sdk": "^1.18.2",
24
- "@grafana/faro-web-tracing": "^1.18.2",
22
+ "@grafana/faro-react": "^1.19.0",
23
+ "@grafana/faro-web-sdk": "^1.19.0",
24
+ "@grafana/faro-web-tracing": "^1.19.0",
25
25
  "@opentelemetry/api": "^1.9.0",
26
26
  "@opentelemetry/core": "2.0.1",
27
- "@opentelemetry/instrumentation-document-load": "^0.47.0",
28
- "@opentelemetry/instrumentation-fetch": "^0.202.0",
29
- "@opentelemetry/instrumentation-user-interaction": "^0.47.0"
27
+ "@opentelemetry/instrumentation-document-load": "^0.48.0",
28
+ "@opentelemetry/instrumentation-fetch": "^0.203.0",
29
+ "@opentelemetry/instrumentation-user-interaction": "^0.48.0"
30
30
  },
31
31
  "devDependencies": {
32
- "@types/node": "^24.0.3",
32
+ "@types/node": "^24.3.0",
33
33
  "@vitest/coverage-v8": "^3.2.4",
34
- "tsx": "^4.20.3",
34
+ "tsx": "^4.20.5",
35
35
  "typescript": "^5.8.3",
36
36
  "vitest": "^3.2.4"
37
37
  },
@@ -0,0 +1,215 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import buildFaroInstrumentation from "../lib/instrumentation.faro";
3
+ import { initializeFaro } from "@grafana/faro-react";
4
+ import { _beforeSend } from "../lib/internals/hooks";
5
+ import { redactors } from "../lib/internals/redaction/redactors";
6
+ import * as detectionTools from "../lib/internals/redaction/pii-detection";
7
+
8
+ // Mock dependencies
9
+ vi.mock("@grafana/faro-react", async () => {
10
+ const actual = await vi.importActual<any>("@grafana/faro-react");
11
+ return {
12
+ ...actual,
13
+ initializeFaro: vi.fn(() => ({ fakeFaro: true })),
14
+ getWebInstrumentations: vi.fn(() => ["webInstrumentation"]),
15
+ ErrorsInstrumentation: vi.fn(() => "errorsInstrumentation"),
16
+ WebVitalsInstrumentation: vi.fn(() => "webVitalsInstrumentation"),
17
+ };
18
+ });
19
+
20
+ vi.mock("@grafana/faro-web-tracing", () => ({
21
+ TracingInstrumentation: vi.fn(() => "tracingInstrumentation"),
22
+ }));
23
+
24
+ vi.mock("@opentelemetry/core", () => ({
25
+ W3CTraceContextPropagator: vi.fn(() => "tracePropagator"),
26
+ }));
27
+
28
+ vi.mock("@opentelemetry/instrumentation-document-load", () => ({
29
+ DocumentLoadInstrumentation: vi.fn(() => "docLoadInstrumentation"),
30
+ }));
31
+
32
+ vi.mock("@opentelemetry/instrumentation-fetch", () => ({
33
+ FetchInstrumentation: vi.fn((opts) => ({
34
+ ...opts,
35
+ name: "fetchInstrumentation",
36
+ })),
37
+ }));
38
+
39
+ vi.mock("@opentelemetry/instrumentation-user-interaction", () => ({
40
+ UserInteractionInstrumentation: vi.fn(() => "userInteractionInstrumentation"),
41
+ }));
42
+
43
+ vi.mock("./internals/hooks", () => ({
44
+ _beforeSend: vi.fn(() => "hooked"),
45
+ }));
46
+
47
+ describe("buildFaroInstrumentation", () => {
48
+ const validConfig = {
49
+ collectorUrl: "https://valid-url.com",
50
+ serviceName: "test-app",
51
+ detection: {
52
+ email: true,
53
+ },
54
+ };
55
+
56
+ beforeEach(() => {
57
+ vi.clearAllMocks();
58
+ console.warn = vi.fn();
59
+ console.error = vi.fn();
60
+ console.log = vi.fn();
61
+ });
62
+
63
+ it("returns undefined and warns if config is not provided", () => {
64
+ const result = buildFaroInstrumentation();
65
+ expect(console.warn).toHaveBeenCalledWith(
66
+ "observability config not set. Skipping Faro OpenTelemetry instrumentation.",
67
+ );
68
+ expect(result).toBeUndefined();
69
+ });
70
+
71
+ it("returns undefined and warns if collectorUrl is missing", () => {
72
+ const result = buildFaroInstrumentation({
73
+ ...validConfig,
74
+ collectorUrl: undefined!,
75
+ });
76
+ expect(console.warn).toHaveBeenCalledWith(
77
+ "collectorUrl not set. Skipping Faro OpenTelemetry instrumentation.",
78
+ );
79
+ expect(result).toBeUndefined();
80
+ });
81
+
82
+ it("returns undefined and errors if collectorUrl is invalid", () => {
83
+ const result = buildFaroInstrumentation({
84
+ ...validConfig,
85
+ collectorUrl: "not-a-url",
86
+ });
87
+ expect(console.error).toHaveBeenCalledWith(
88
+ "collectorUrl does not use a valid format. Skipping Faro OpenTelemetry instrumentation.",
89
+ );
90
+ expect(result).toBeUndefined();
91
+ });
92
+
93
+ it("defaults all detection to true if not provided", () => {
94
+ const config = {
95
+ ...validConfig,
96
+ detection: {},
97
+ };
98
+ buildFaroInstrumentation(config);
99
+ expect(initializeFaro).toHaveBeenCalledOnce();
100
+ expect(initializeFaro.mock.calls[0][0].beforeSend.toString()).toEqual(
101
+ _beforeSend(Object.values(redactors)).toString(),
102
+ );
103
+ });
104
+
105
+ it("sets beforeSend if detection.email is true", () => {
106
+ buildFaroInstrumentation({ ...validConfig, detection: { email: true } });
107
+ expect(initializeFaro).toHaveBeenCalledOnce();
108
+ expect(initializeFaro.mock.calls[0][0].beforeSend.toString()).toEqual(
109
+ _beforeSend(Object.values(redactors)).toString(),
110
+ );
111
+ });
112
+
113
+ it("does not set beforeSend if detection.email is false", () => {
114
+ buildFaroInstrumentation({ ...validConfig, detection: { email: false } });
115
+ expect(initializeFaro).toHaveBeenCalledOnce();
116
+ expect(initializeFaro.mock.calls[0][0].beforeSend.toString()).toEqual(
117
+ _beforeSend(
118
+ Object.entries(redactors)
119
+ .filter(([key]) => key !== "email")
120
+ .map(([, redactor]) => redactor),
121
+ ).toString(),
122
+ );
123
+ });
124
+
125
+ it("disables batching when collectorMode is 'single'", () => {
126
+ buildFaroInstrumentation({ ...validConfig, collectorMode: "single" });
127
+ expect(initializeFaro).toHaveBeenCalledWith(
128
+ expect.objectContaining({
129
+ batching: {
130
+ enabled: false,
131
+ },
132
+ }),
133
+ );
134
+ });
135
+
136
+ it("sets diagLogLevel if provided", async () => {
137
+ const { DiagLogLevel } = await import("@opentelemetry/api");
138
+ const setLoggerSpy = vi
139
+ .spyOn(await import("@opentelemetry/api"), "diag", "get")
140
+ .mockReturnValue({
141
+ setLogger: vi.fn(),
142
+ });
143
+
144
+ buildFaroInstrumentation({ ...validConfig, diagLogLevel: "DEBUG" });
145
+
146
+ expect(setLoggerSpy().setLogger).toHaveBeenCalledWith(
147
+ expect.anything(),
148
+ DiagLogLevel.DEBUG,
149
+ );
150
+ });
151
+
152
+ it("handles initialization failure and logs error", () => {
153
+ (initializeFaro as any).mockImplementationOnce(() => {
154
+ throw new Error("init failed");
155
+ });
156
+
157
+ const result = buildFaroInstrumentation(validConfig);
158
+ expect(console.error).toHaveBeenCalledWith(
159
+ "Error starting Faro OpenTelemetry instrumentation:",
160
+ expect.any(Error),
161
+ );
162
+ expect(result).toBeUndefined();
163
+ });
164
+
165
+ it("returns the faro object on successful initialization", () => {
166
+ const result = buildFaroInstrumentation(validConfig);
167
+ expect(console.log).toHaveBeenCalledWith(
168
+ "Faro OpenTelemetry instrumentation started successfully.",
169
+ );
170
+ expect(result).toEqual({ fakeFaro: true });
171
+ });
172
+
173
+ it("skips detection if all redactors are set to false", () => {
174
+ const config = {
175
+ ...validConfig,
176
+ detection: Object.fromEntries(
177
+ Object.keys(redactors).map((key) => [key, false]),
178
+ ),
179
+ };
180
+
181
+ buildFaroInstrumentation(config);
182
+
183
+ const cleanStringMock = vi.spyOn(detectionTools, "_cleanStringPII");
184
+
185
+ expect(initializeFaro).toHaveBeenCalledOnce();
186
+ expect(initializeFaro.mock.calls[0][0].beforeSend).toBeTruthy();
187
+
188
+ initializeFaro.mock.calls[0][0].beforeSend({
189
+ type: "log",
190
+ payload: { name: "eventName", timestamp: "a timestamp" },
191
+ meta: {},
192
+ });
193
+
194
+ expect(cleanStringMock).not.toHaveBeenCalled();
195
+ });
196
+
197
+ it("handles comma-separated corsTraceHeaders", async () => {
198
+ buildFaroInstrumentation({
199
+ ...validConfig,
200
+ corsTraceHeaders: "https://site1.com,https://site2.com",
201
+ });
202
+
203
+ const { FetchInstrumentation } = await import(
204
+ "@opentelemetry/instrumentation-fetch"
205
+ );
206
+ expect(FetchInstrumentation).toHaveBeenCalledWith(
207
+ expect.objectContaining({
208
+ propagateTraceHeaderCorsUrls: [
209
+ new RegExp("https://site1.com"),
210
+ new RegExp("https://site2.com"),
211
+ ],
212
+ }),
213
+ );
214
+ });
215
+ });
@@ -0,0 +1,150 @@
1
+ import {
2
+ LogEvent,
3
+ LogLevel,
4
+ TransportItem,
5
+ TransportItemType,
6
+ } from "@grafana/faro-web-sdk";
7
+ import { beforeEach, describe, expect, it, vi } from "vitest";
8
+ import { _beforeSend } from "../../lib/internals/hooks";
9
+
10
+ // Mock faro.api.pushMeasurement
11
+ vi.mock("@grafana/faro-web-sdk", async () => {
12
+ const actual = await vi.importActual<any>("@grafana/faro-web-sdk");
13
+ return {
14
+ ...actual,
15
+ faro: {
16
+ api: {
17
+ pushMeasurement: vi.fn(),
18
+ },
19
+ },
20
+ };
21
+ });
22
+
23
+ import { faro } from "@grafana/faro-web-sdk";
24
+ import { redactors } from "../../lib/internals/redaction/redactors";
25
+
26
+ describe("_beforeSend", () => {
27
+ beforeEach(() => {
28
+ vi.clearAllMocks();
29
+ });
30
+
31
+ it("should deeply redact PII in object", () => {
32
+ const item = {
33
+ type: TransportItemType.LOG,
34
+ payload: {
35
+ level: LogLevel.INFO,
36
+ context: {},
37
+ timestamp: Date.now().toString(),
38
+ message: "my email is bob@abc.com",
39
+ },
40
+ meta: {
41
+ user: {
42
+ email: "carol@abc.com",
43
+ attributes: {
44
+ alternative_email: "foo@xyz.org",
45
+ },
46
+ },
47
+ },
48
+ } satisfies TransportItem<LogEvent>;
49
+
50
+ const redacted: TransportItem<LogEvent> = _beforeSend([redactors.email])(
51
+ item,
52
+ ) as TransportItem<LogEvent>;
53
+
54
+ expect(redacted.payload.message).toBe("my email is [REDACTED EMAIL]");
55
+ expect(redacted.meta.user).not.toBeNull();
56
+ expect(redacted.meta.user!.email).toBe("[REDACTED EMAIL]");
57
+ expect(redacted.meta.user!.attributes).toBeDefined();
58
+ expect(redacted.meta.user!.attributes!["alternative_email"]).toBe(
59
+ "[REDACTED EMAIL]",
60
+ );
61
+ expect(faro.api.pushMeasurement).toHaveBeenCalledTimes(3);
62
+ });
63
+
64
+ it("should redact email inside arrays of primitives", () => {
65
+ const item: TransportItem<LogEvent> = {
66
+ type: TransportItemType.LOG,
67
+ payload: {
68
+ message: "start",
69
+ context: {
70
+ messages: ["hello", "email: test@abc.com", "ok"],
71
+ },
72
+ timestamp: "123",
73
+ level: LogLevel.INFO,
74
+ },
75
+ };
76
+
77
+ const redacted = _beforeSend([redactors.email])(item)!;
78
+
79
+ expect(redacted.payload.context.messages[1]).toBe(
80
+ "email: [REDACTED EMAIL]",
81
+ );
82
+ expect(faro.api.pushMeasurement).toHaveBeenCalledOnce();
83
+ });
84
+
85
+ it("should redact emails in arrays of objects", () => {
86
+ const item: TransportItem<LogEvent> = {
87
+ type: TransportItemType.LOG,
88
+ payload: {
89
+ message: "checking",
90
+ context: {
91
+ events: [
92
+ { message: "contact a@b.com" },
93
+ { message: "nothing here" },
94
+ { note: "try c@d.net" },
95
+ ],
96
+ },
97
+ timestamp: "123",
98
+ level: LogLevel.INFO,
99
+ },
100
+ };
101
+
102
+ const redacted = _beforeSend([redactors.email])(item)!;
103
+
104
+ expect(redacted.payload.context.events[0].message).toBe(
105
+ "contact [REDACTED EMAIL]",
106
+ );
107
+ expect(redacted.payload.context.events[2].note).toBe(
108
+ "try [REDACTED EMAIL]",
109
+ );
110
+ expect(faro.api.pushMeasurement).toHaveBeenCalledTimes(2);
111
+ });
112
+
113
+ it("should not break on nulls, undefined, numbers, booleans", () => {
114
+ const item: TransportItem<LogEvent> = {
115
+ type: TransportItemType.LOG,
116
+ payload: {
117
+ message: null as any,
118
+ count: 123,
119
+ active: true,
120
+ description: undefined,
121
+ } as any,
122
+ meta: null,
123
+ };
124
+
125
+ const redacted = _beforeSend([redactors.email])(item)!;
126
+
127
+ expect(redacted.payload.message).toBeNull();
128
+ expect(redacted.payload.count).toBe(123);
129
+ expect(redacted.payload.active).toBe(true);
130
+ expect("description" in redacted.payload).toBe(true); // still defined as undefined
131
+ expect(faro.api.pushMeasurement).not.toHaveBeenCalled();
132
+ });
133
+
134
+ it("should decode and redact encoded emails", () => {
135
+ const item: TransportItem<LogEvent> = {
136
+ type: TransportItemType.LOG,
137
+ payload: {
138
+ message: "contact%20user%40domain.com",
139
+ level: LogLevel.INFO,
140
+ context: {},
141
+ timestamp: "now",
142
+ },
143
+ };
144
+
145
+ const redacted = _beforeSend([redactors.email])(item)!;
146
+
147
+ expect(redacted.payload.message).toBe("contact [REDACTED EMAIL]");
148
+ expect(faro.api.pushMeasurement).toHaveBeenCalledOnce();
149
+ });
150
+ });
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it, vi, beforeEach, afterAll } from "vitest";
2
+
3
+ import { emailRedactor } from "../../../../lib/internals/redaction/redactors/email";
4
+ import { ipRedactor } from "../../../../lib/internals/redaction/redactors/ip";
5
+ import { faro, TransportItemType } from "@grafana/faro-web-sdk";
6
+
7
+ // Mock faro.api.pushMeasurement
8
+ vi.mock("@grafana/faro-web-sdk", async () => {
9
+ const actual = await vi.importActual<any>("@grafana/faro-web-sdk");
10
+ return {
11
+ ...actual,
12
+ faro: {
13
+ api: {
14
+ pushMeasurement: vi.fn(),
15
+ },
16
+ },
17
+ };
18
+ });
19
+
20
+ describe("Email Redaction utils", () => {
21
+ afterAll(() => {
22
+ vi.restoreAllMocks();
23
+ });
24
+
25
+ describe("tracks metrics", () => {
26
+ beforeEach(() => {
27
+ vi.clearAllMocks();
28
+ });
29
+
30
+ it("redacts plain PII and tracks redaction with metric", () => {
31
+ const input = "admin@example.com";
32
+ const output = emailRedactor(input, "log", "string");
33
+
34
+ expect(output).toBe("[REDACTED EMAIL]");
35
+ expect(faro.api.pushMeasurement).toHaveBeenCalledWith(
36
+ {
37
+ type: "faro_o11y_pii_redaction",
38
+ values: { redacted: 1 },
39
+ },
40
+ {
41
+ context: {
42
+ pii_type: "email",
43
+ redaction_source: TransportItemType.LOG,
44
+ pii_email_domain: "example.com",
45
+ pii_format: "string",
46
+ },
47
+ },
48
+ );
49
+ });
50
+
51
+ it("handles strings without PII unchanged", () => {
52
+ const input = "hello world";
53
+ const output = ipRedactor(input, "log", "string");
54
+
55
+ expect(output).toBe("hello world");
56
+ expect(faro.api.pushMeasurement).not.toHaveBeenCalled();
57
+ });
58
+ });
59
+
60
+ describe("Redacts email addresses", () => {
61
+ it.each`
62
+ value | expectedRedactedValue
63
+ ${"user+tag@example.com"} | ${"[REDACTED EMAIL]"}
64
+ ${"user.name+tag+sorting@example.com"} | ${"[REDACTED EMAIL]"}
65
+ ${"x@example.museum"} | ${"[REDACTED EMAIL]"}
66
+ ${"a.b-c_d@example.co.uk"} | ${"[REDACTED EMAIL]"}
67
+ ${"üser@example.de"} | ${"[REDACTED EMAIL]"}
68
+ ${"john.doe@xn--exmple-cua.com"} | ${"[REDACTED EMAIL]"}
69
+ ${"üser@example.de"} | ${"[REDACTED EMAIL]"}
70
+ ${"plainaddress"} | ${"plainaddress"}
71
+ ${"@missinglocal.org"} | ${"@missinglocal.org"}
72
+ ${"user@invalid_domain.com"} | ${"user@invalid_domain.com"}
73
+ `(
74
+ "returns $expectedRedactedValue for value '$value'",
75
+ async ({ value, expectedRedactedValue }: Record<string, string>) => {
76
+ const result = emailRedactor(value, "log", "string");
77
+ expect(result).toBe(expectedRedactedValue);
78
+ },
79
+ );
80
+ });
81
+ });
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it, vi, afterAll } from "vitest";
2
+
3
+ import { faro } from "@grafana/faro-web-sdk";
4
+ import { ipRedactor } from "../../../../lib/internals/redaction/redactors/ip";
5
+
6
+ // Mock faro.api.pushMeasurement
7
+ vi.mock("@grafana/faro-web-sdk", async () => {
8
+ const actual = await vi.importActual<any>("@grafana/faro-web-sdk");
9
+ return {
10
+ ...actual,
11
+ faro: {
12
+ api: {
13
+ pushMeasurement: vi.fn(),
14
+ },
15
+ },
16
+ };
17
+ });
18
+
19
+ describe("IP Redaction utils", () => {
20
+ afterAll(() => {
21
+ vi.restoreAllMocks();
22
+ });
23
+
24
+ describe("tracks metrics", () => {
25
+ it("redacts plain PII and tracks redaction with metric", () => {
26
+ const input = "255.255.255.255";
27
+ const output = ipRedactor(input, "log", "string");
28
+
29
+ expect(output).toBe("[REDACTED IPV4]");
30
+ expect(faro.api.pushMeasurement).toHaveBeenCalledWith(
31
+ {
32
+ type: "faro_o11y_pii_redaction",
33
+ values: {
34
+ redacted: 1,
35
+ },
36
+ },
37
+ {
38
+ context: {
39
+ pii_format: "string",
40
+ pii_type: "IPv4",
41
+ redaction_source: "log",
42
+ },
43
+ },
44
+ );
45
+ });
46
+
47
+ it("handles strings without PII unchanged", () => {
48
+ const input = "hello world";
49
+ const output = ipRedactor(input, "log", "string");
50
+
51
+ expect(output).toBe("hello world");
52
+ expect(faro.api.pushMeasurement).not.toHaveBeenCalled();
53
+ });
54
+ });
55
+
56
+ describe("Redacts IPv4 and IPv6", () => {
57
+ it.each`
58
+ value | expectedRedactedValue
59
+ ${"hello world"} | ${"hello world"}
60
+ ${"hello 127.0.0.1"} | ${"hello [REDACTED IPV4]"}
61
+ ${"127.0.0.1, hello!"} | ${"[REDACTED IPV4], hello!"}
62
+ ${"127.0.0.1,127.0.0.1"} | ${"[REDACTED IPV4],[REDACTED IPV4]"}
63
+ ${"127.0.0.1127.0.0.1"} | ${"127.0.0.1127.0.0.1"}
64
+ ${"256.1.1.1"} | ${"256.1.1.1"}
65
+ ${"0.0.0.0!"} | ${"[REDACTED IPV4]!"}
66
+ ${"text0.0.0.0"} | ${"text[REDACTED IPV4]"}
67
+ ${"0.0.text0.0"} | ${"0.0.text0.0"}
68
+ ${"2001:0db8::1"} | ${"[REDACTED IPV6]"}
69
+ ${"::1"} | ${"[REDACTED IPV6]"}
70
+ ${"text::1"} | ${"text[REDACTED IPV6]"}
71
+ ${"::1text"} | ${"[REDACTED IPV6]text"}
72
+ ${"sentence ending with f::1"} | ${"sentence ending with [REDACTED IPV6]"}
73
+ ${"sentence ending with :::1"} | ${"sentence ending with :::1"}
74
+ ${"2001:0DB8:85A3:0000:0000:8A2E:0370:7334::1"} | ${"2001:0DB8:85A3:0000:0000:8A2E:0370:7334::1"}
75
+ ${"2001:0DB8:85A3:0000:text:8A2E:0370:7334"} | ${"2001:0DB8:85A3:0000:text:8A2E:0370:7334"}
76
+ ${"2001:0db8::12001:0db8::1"} | ${"2001:0db8::12001:0db8::1"}
77
+ ${"2001:0db8::1,2001:0db8::1"} | ${"[REDACTED IPV6],[REDACTED IPV6]"}
78
+ `(
79
+ "returns $expectedRedactedValue for value '$value'",
80
+ async ({ value, expectedRedactedValue }: Record<string, string>) => {
81
+ const result = ipRedactor(value, "log", "string");
82
+ expect(result).toBe(expectedRedactedValue);
83
+ },
84
+ );
85
+ });
86
+ });