@ogcio/o11y-sdk-react 0.1.0-beta.7 → 0.3.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.
- package/CHANGELOG.md +21 -0
- package/dist/lib/index.d.ts +10 -0
- package/dist/lib/instrumentation.faro.d.ts +1 -1
- package/dist/lib/instrumentation.faro.js +12 -2
- package/dist/lib/internals/hooks.d.ts +2 -0
- package/dist/lib/internals/hooks.js +32 -0
- package/dist/lib/internals/pii-detection.d.ts +12 -0
- package/dist/lib/internals/pii-detection.js +78 -0
- package/dist/vitest.config.js +1 -1
- package/lib/index.ts +10 -0
- package/lib/instrumentation.faro.ts +15 -4
- package/lib/internals/hooks.ts +45 -0
- package/lib/internals/pii-detection.ts +96 -0
- package/package.json +5 -5
- package/test/config.test.ts +202 -0
- package/test/lib/hooks.test.ts +149 -0
- package/test/lib/pii-detection.test.ts +70 -0
- package/vitest.config.ts +1 -1
- package/test/index.test.ts +0 -28
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.0](https://github.com/ogcio/o11y/compare/@ogcio/o11y-sdk-react@v0.2.0...@ogcio/o11y-sdk-react@v0.3.0) (2025-07-31)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **sdk-react:** pii detection ([#180](https://github.com/ogcio/o11y/issues/180)) ([57ddf2f](https://github.com/ogcio/o11y/commit/57ddf2f4792207e62ea6bf9ea797bc652942db06))
|
|
9
|
+
* update AWS secrets manager connection AB[#29565](https://github.com/ogcio/o11y/issues/29565) ([#166](https://github.com/ogcio/o11y/issues/166)) ([962c03d](https://github.com/ogcio/o11y/commit/962c03de2da4060a81aa603c355f3d60cafe3c30))
|
|
10
|
+
|
|
11
|
+
## [0.2.0](https://github.com/ogcio/o11y/compare/@ogcio/o11y-sdk-react@v0.1.0-beta.7...@ogcio/o11y-sdk-react@v0.2.0) (2025-06-24)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
* late june deps update ([#154](https://github.com/ogcio/o11y/issues/154)) ([a772622](https://github.com/ogcio/o11y/commit/a7726225a59b2dbcd4326919323d935843b2f93f))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Miscellaneous Chores
|
|
20
|
+
|
|
21
|
+
* **deps-dev:** bump @types/node to `24` AB[#29294](https://github.com/ogcio/o11y/issues/29294) ([3c74c28](https://github.com/ogcio/o11y/commit/3c74c28af41af8403220368ac72a90e40c3e15ae))
|
|
22
|
+
* **deps-dev:** bump the root-deps group across 1 directory with 3 updates ([#150](https://github.com/ogcio/o11y/issues/150)) ([9d574d1](https://github.com/ogcio/o11y/commit/9d574d1af0964fc4af991d677b443015921d93f6))
|
|
23
|
+
|
|
3
24
|
## [0.1.0-beta.7](https://github.com/ogcio/o11y/compare/@ogcio/o11y-sdk-react@v0.1.0-beta.6...@ogcio/o11y-sdk-react@v0.1.0-beta.7) (2025-06-04)
|
|
4
25
|
|
|
5
26
|
|
package/dist/lib/index.d.ts
CHANGED
|
@@ -33,6 +33,16 @@ export interface FaroSDKConfig extends SDKConfig {
|
|
|
33
33
|
* @example http://localhost:9000
|
|
34
34
|
*/
|
|
35
35
|
corsTraceHeaders?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Enable/Disable PII detection for GDPR data
|
|
38
|
+
*/
|
|
39
|
+
detection?: {
|
|
40
|
+
/**
|
|
41
|
+
* Redact email address
|
|
42
|
+
* @default true
|
|
43
|
+
*/
|
|
44
|
+
email?: boolean;
|
|
45
|
+
};
|
|
36
46
|
}
|
|
37
47
|
export type SDKCollectorMode = "single" | "batch";
|
|
38
48
|
export type SDKLogLevel = "NONE" | "ERROR" | "WARN" | "INFO" | "DEBUG" | "VERBOSE" | "ALL";
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { ErrorsInstrumentation, getWebInstrumentations, initializeFaro, WebVitalsInstrumentation, } from "@grafana/faro-react";
|
|
2
|
+
import { TracingInstrumentation } from "@grafana/faro-web-tracing";
|
|
2
3
|
import { diag, DiagConsoleLogger, DiagLogLevel } from "@opentelemetry/api";
|
|
3
4
|
import { W3CTraceContextPropagator } from "@opentelemetry/core";
|
|
5
|
+
import { DocumentLoadInstrumentation } from "@opentelemetry/instrumentation-document-load";
|
|
4
6
|
import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
|
|
5
7
|
import { UserInteractionInstrumentation } from "@opentelemetry/instrumentation-user-interaction";
|
|
6
|
-
import {
|
|
7
|
-
import { TracingInstrumentation } from "@grafana/faro-web-tracing";
|
|
8
|
+
import { _beforeSend } from "./internals/hooks.js";
|
|
8
9
|
export default function buildFaroInstrumentation(config) {
|
|
9
10
|
if (!config) {
|
|
10
11
|
console.warn("observability config not set. Skipping Faro OpenTelemetry instrumentation.");
|
|
@@ -18,6 +19,14 @@ export default function buildFaroInstrumentation(config) {
|
|
|
18
19
|
console.error("collectorUrl does not use a valid format. Skipping Faro OpenTelemetry instrumentation.");
|
|
19
20
|
return;
|
|
20
21
|
}
|
|
22
|
+
if (!config.detection) {
|
|
23
|
+
config.detection = {
|
|
24
|
+
email: true,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (config.detection.email === undefined) {
|
|
28
|
+
config.detection.email = true;
|
|
29
|
+
}
|
|
21
30
|
try {
|
|
22
31
|
diag.setLogger(new DiagConsoleLogger(), config.diagLogLevel
|
|
23
32
|
? DiagLogLevel[config.diagLogLevel]
|
|
@@ -30,6 +39,7 @@ export default function buildFaroInstrumentation(config) {
|
|
|
30
39
|
batching: {
|
|
31
40
|
enabled: !(config.collectorMode === "single"),
|
|
32
41
|
},
|
|
42
|
+
beforeSend: config.detection.email ? _beforeSend : undefined,
|
|
33
43
|
instrumentations: [
|
|
34
44
|
...getWebInstrumentations({
|
|
35
45
|
captureConsole: true,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { _cleanStringPII } from "./pii-detection.js";
|
|
2
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3
|
+
function _cleanObjectPII(data, type) {
|
|
4
|
+
if (!data) {
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
if (Array.isArray(data)) {
|
|
8
|
+
data.forEach((item, index) => {
|
|
9
|
+
if (typeof item === "string") {
|
|
10
|
+
data[index] = _cleanStringPII(data[index], type);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (typeof item === "object") {
|
|
14
|
+
_cleanObjectPII(item, type);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
if (typeof data === "object") {
|
|
19
|
+
for (const key in data) {
|
|
20
|
+
if (typeof data[key] === "string") {
|
|
21
|
+
data[key] = _cleanStringPII(data[key], type);
|
|
22
|
+
}
|
|
23
|
+
if (typeof data[key] === "object") {
|
|
24
|
+
_cleanObjectPII(data[key], type);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function _beforeSend(item) {
|
|
30
|
+
_cleanObjectPII(item, item.type);
|
|
31
|
+
return item;
|
|
32
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { TransportItemType } from "@grafana/faro-web-sdk";
|
|
2
|
+
/**
|
|
3
|
+
* Cleans a string by redacting email addresses and emitting metrics for PII.
|
|
4
|
+
*
|
|
5
|
+
* If the string is URL-encoded, it will be decoded before redaction.
|
|
6
|
+
* Metrics are emitted for each domain found in redacted email addresses.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} value - The input string to sanitize.
|
|
9
|
+
* @param {TransportItemType} source - The source context of the input.
|
|
10
|
+
* @returns {string} The cleaned string with any email addresses replaced by `[REDACTED EMAIL]`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function _cleanStringPII(value: string, source: TransportItemType): string;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { faro } from "@grafana/faro-web-sdk";
|
|
2
|
+
const EMAIL_REGEX = /[a-zA-Z0-9._%+-]+@([a-zA-Z0-9.-]+\.[a-z]{2,})/gi;
|
|
3
|
+
/**
|
|
4
|
+
* Redacts all email addresses in the input string and collects metadata.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} value The input string potentially containing email addresses.
|
|
7
|
+
* @returns {{
|
|
8
|
+
* redacted: string,
|
|
9
|
+
* count: number,
|
|
10
|
+
* domains: Record<string, number>
|
|
11
|
+
* }}
|
|
12
|
+
*
|
|
13
|
+
* An object containing:
|
|
14
|
+
* - `redacted`: the string with email addresses replaced by `[REDACTED EMAIL]`
|
|
15
|
+
* - `count`: total number of email addresses redacted
|
|
16
|
+
* - `domains`: a map of domain names to the number of times they were redacted
|
|
17
|
+
*/
|
|
18
|
+
function _redactEmails(value) {
|
|
19
|
+
let count = 0;
|
|
20
|
+
const domains = {};
|
|
21
|
+
const redacted = value.replace(EMAIL_REGEX, (_, domain) => {
|
|
22
|
+
count++;
|
|
23
|
+
domains[domain] = (domains[domain] || 0) + 1;
|
|
24
|
+
return "[REDACTED EMAIL]";
|
|
25
|
+
});
|
|
26
|
+
return { redacted, count, domains };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Checks whether a string contains URI-encoded components.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} value - The string to inspect.
|
|
32
|
+
* @returns {boolean} `true` if the string is encoded, `false` otherwise.
|
|
33
|
+
*/
|
|
34
|
+
function _containsEncodedComponents(value) {
|
|
35
|
+
try {
|
|
36
|
+
return decodeURI(value) !== decodeURIComponent(value);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Cleans a string by redacting email addresses and emitting metrics for PII.
|
|
44
|
+
*
|
|
45
|
+
* If the string is URL-encoded, it will be decoded before redaction.
|
|
46
|
+
* Metrics are emitted for each domain found in redacted email addresses.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} value - The input string to sanitize.
|
|
49
|
+
* @param {TransportItemType} source - The source context of the input.
|
|
50
|
+
* @returns {string} The cleaned string with any email addresses replaced by `[REDACTED EMAIL]`.
|
|
51
|
+
*/
|
|
52
|
+
export function _cleanStringPII(value, source) {
|
|
53
|
+
let kind = "string";
|
|
54
|
+
let decodedValue = value;
|
|
55
|
+
if (_containsEncodedComponents(value)) {
|
|
56
|
+
decodedValue = decodeURIComponent(value);
|
|
57
|
+
kind = "url";
|
|
58
|
+
}
|
|
59
|
+
const { redacted, count, domains } = _redactEmails(decodedValue);
|
|
60
|
+
if (count > 0) {
|
|
61
|
+
for (const [domain, domainCount] of Object.entries(domains)) {
|
|
62
|
+
faro.api.pushMeasurement({
|
|
63
|
+
type: "faro_o11y_pii_redaction",
|
|
64
|
+
values: {
|
|
65
|
+
redacted: domainCount,
|
|
66
|
+
},
|
|
67
|
+
}, {
|
|
68
|
+
context: {
|
|
69
|
+
pii_type: "email",
|
|
70
|
+
redaction_source: source,
|
|
71
|
+
pii_email_domain: domain,
|
|
72
|
+
pii_format: kind,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return redacted;
|
|
78
|
+
}
|
package/dist/vitest.config.js
CHANGED
package/lib/index.ts
CHANGED
|
@@ -34,6 +34,16 @@ export interface FaroSDKConfig extends SDKConfig {
|
|
|
34
34
|
* @example http://localhost:9000
|
|
35
35
|
*/
|
|
36
36
|
corsTraceHeaders?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Enable/Disable PII detection for GDPR data
|
|
39
|
+
*/
|
|
40
|
+
detection?: {
|
|
41
|
+
/**
|
|
42
|
+
* Redact email address
|
|
43
|
+
* @default true
|
|
44
|
+
*/
|
|
45
|
+
email?: boolean;
|
|
46
|
+
};
|
|
37
47
|
}
|
|
38
48
|
|
|
39
49
|
export type SDKCollectorMode = "single" | "batch";
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { FaroSDKConfig } from "./index.js";
|
|
2
1
|
import {
|
|
3
2
|
ErrorsInstrumentation,
|
|
4
3
|
getWebInstrumentations,
|
|
@@ -6,12 +5,14 @@ import {
|
|
|
6
5
|
WebVitalsInstrumentation,
|
|
7
6
|
type Faro,
|
|
8
7
|
} from "@grafana/faro-react";
|
|
8
|
+
import { TracingInstrumentation } from "@grafana/faro-web-tracing";
|
|
9
9
|
import { diag, DiagConsoleLogger, DiagLogLevel } from "@opentelemetry/api";
|
|
10
10
|
import { W3CTraceContextPropagator } from "@opentelemetry/core";
|
|
11
|
+
import { DocumentLoadInstrumentation } from "@opentelemetry/instrumentation-document-load";
|
|
11
12
|
import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
|
|
12
13
|
import { UserInteractionInstrumentation } from "@opentelemetry/instrumentation-user-interaction";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
14
|
+
import type { FaroSDKConfig } from "./index.js";
|
|
15
|
+
import { _beforeSend } from "./internals/hooks.js";
|
|
15
16
|
|
|
16
17
|
export default function buildFaroInstrumentation(
|
|
17
18
|
config?: FaroSDKConfig,
|
|
@@ -37,6 +38,16 @@ export default function buildFaroInstrumentation(
|
|
|
37
38
|
return;
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
if (!config.detection) {
|
|
42
|
+
config.detection = {
|
|
43
|
+
email: true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (config.detection.email === undefined) {
|
|
48
|
+
config.detection.email = true;
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
try {
|
|
41
52
|
diag.setLogger(
|
|
42
53
|
new DiagConsoleLogger(),
|
|
@@ -53,11 +64,11 @@ export default function buildFaroInstrumentation(
|
|
|
53
64
|
batching: {
|
|
54
65
|
enabled: !(config.collectorMode === "single"),
|
|
55
66
|
},
|
|
67
|
+
beforeSend: config.detection.email ? _beforeSend : undefined,
|
|
56
68
|
instrumentations: [
|
|
57
69
|
...getWebInstrumentations({
|
|
58
70
|
captureConsole: true,
|
|
59
71
|
}),
|
|
60
|
-
|
|
61
72
|
new ErrorsInstrumentation(),
|
|
62
73
|
new WebVitalsInstrumentation(),
|
|
63
74
|
new TracingInstrumentation({
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {
|
|
2
|
+
APIEvent,
|
|
3
|
+
TransportItem,
|
|
4
|
+
TransportItemType,
|
|
5
|
+
} from "@grafana/faro-web-sdk";
|
|
6
|
+
import { _cleanStringPII } from "./pii-detection.js";
|
|
7
|
+
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
|
+
function _cleanObjectPII(data: any, type: TransportItemType) {
|
|
10
|
+
if (!data) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (Array.isArray(data)) {
|
|
15
|
+
data.forEach((item, index) => {
|
|
16
|
+
if (typeof item === "string") {
|
|
17
|
+
data[index] = _cleanStringPII(data[index], type);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (typeof item === "object") {
|
|
22
|
+
_cleanObjectPII(item, type);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (typeof data === "object") {
|
|
28
|
+
for (const key in data) {
|
|
29
|
+
if (typeof data[key] === "string") {
|
|
30
|
+
data[key] = _cleanStringPII(data[key], type);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (typeof data[key] === "object") {
|
|
34
|
+
_cleanObjectPII(data[key], type);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function _beforeSend(
|
|
41
|
+
item: TransportItem<APIEvent>,
|
|
42
|
+
): TransportItem<APIEvent> | null {
|
|
43
|
+
_cleanObjectPII(item, item.type);
|
|
44
|
+
return item;
|
|
45
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { faro, TransportItemType } from "@grafana/faro-web-sdk";
|
|
2
|
+
|
|
3
|
+
const EMAIL_REGEX = /[a-zA-Z0-9._%+-]+@([a-zA-Z0-9.-]+\.[a-z]{2,})/gi;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Redacts all email addresses in the input string and collects metadata.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} value The input string potentially containing email addresses.
|
|
9
|
+
* @returns {{
|
|
10
|
+
* redacted: string,
|
|
11
|
+
* count: number,
|
|
12
|
+
* domains: Record<string, number>
|
|
13
|
+
* }}
|
|
14
|
+
*
|
|
15
|
+
* An object containing:
|
|
16
|
+
* - `redacted`: the string with email addresses replaced by `[REDACTED EMAIL]`
|
|
17
|
+
* - `count`: total number of email addresses redacted
|
|
18
|
+
* - `domains`: a map of domain names to the number of times they were redacted
|
|
19
|
+
*/
|
|
20
|
+
function _redactEmails(value: string): {
|
|
21
|
+
redacted: string;
|
|
22
|
+
count: number;
|
|
23
|
+
domains: Record<string, number>;
|
|
24
|
+
} {
|
|
25
|
+
let count = 0;
|
|
26
|
+
const domains: Record<string, number> = {};
|
|
27
|
+
|
|
28
|
+
const redacted = value.replace(EMAIL_REGEX, (_, domain) => {
|
|
29
|
+
count++;
|
|
30
|
+
domains[domain] = (domains[domain] || 0) + 1;
|
|
31
|
+
return "[REDACTED EMAIL]";
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return { redacted, count, domains };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Checks whether a string contains URI-encoded components.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} value - The string to inspect.
|
|
41
|
+
* @returns {boolean} `true` if the string is encoded, `false` otherwise.
|
|
42
|
+
*/
|
|
43
|
+
function _containsEncodedComponents(value: string) {
|
|
44
|
+
try {
|
|
45
|
+
return decodeURI(value) !== decodeURIComponent(value);
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Cleans a string by redacting email addresses and emitting metrics for PII.
|
|
53
|
+
*
|
|
54
|
+
* If the string is URL-encoded, it will be decoded before redaction.
|
|
55
|
+
* Metrics are emitted for each domain found in redacted email addresses.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} value - The input string to sanitize.
|
|
58
|
+
* @param {TransportItemType} source - The source context of the input.
|
|
59
|
+
* @returns {string} The cleaned string with any email addresses replaced by `[REDACTED EMAIL]`.
|
|
60
|
+
*/
|
|
61
|
+
export function _cleanStringPII(
|
|
62
|
+
value: string,
|
|
63
|
+
source: TransportItemType,
|
|
64
|
+
): string {
|
|
65
|
+
let kind: "string" | "url" = "string";
|
|
66
|
+
let decodedValue = value;
|
|
67
|
+
|
|
68
|
+
if (_containsEncodedComponents(value)) {
|
|
69
|
+
decodedValue = decodeURIComponent(value);
|
|
70
|
+
kind = "url";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const { redacted, count, domains } = _redactEmails(decodedValue);
|
|
74
|
+
|
|
75
|
+
if (count > 0) {
|
|
76
|
+
for (const [domain, domainCount] of Object.entries(domains)) {
|
|
77
|
+
faro.api.pushMeasurement(
|
|
78
|
+
{
|
|
79
|
+
type: "faro_o11y_pii_redaction",
|
|
80
|
+
values: {
|
|
81
|
+
redacted: domainCount,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
context: {
|
|
86
|
+
pii_type: "email",
|
|
87
|
+
redaction_source: source,
|
|
88
|
+
pii_email_domain: domain,
|
|
89
|
+
pii_format: kind,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return redacted;
|
|
96
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ogcio/o11y-sdk-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Opentelemetry standard instrumentation SDK for React based project",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -29,11 +29,11 @@
|
|
|
29
29
|
"@opentelemetry/instrumentation-user-interaction": "^0.47.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@types/node": "^
|
|
33
|
-
"@vitest/coverage-v8": "^3.2.
|
|
34
|
-
"tsx": "^4.
|
|
32
|
+
"@types/node": "^24.0.10",
|
|
33
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
34
|
+
"tsx": "^4.20.3",
|
|
35
35
|
"typescript": "^5.8.3",
|
|
36
|
-
"vitest": "^3.2.
|
|
36
|
+
"vitest": "^3.2.4"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
@@ -0,0 +1,202 @@
|
|
|
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 { FaroSDKConfig } from "../lib";
|
|
6
|
+
|
|
7
|
+
// Mock dependencies
|
|
8
|
+
vi.mock("@grafana/faro-react", async () => {
|
|
9
|
+
const actual = await vi.importActual<any>("@grafana/faro-react");
|
|
10
|
+
return {
|
|
11
|
+
...actual,
|
|
12
|
+
initializeFaro: vi.fn(() => ({ fakeFaro: true })),
|
|
13
|
+
getWebInstrumentations: vi.fn(() => ["webInstrumentation"]),
|
|
14
|
+
ErrorsInstrumentation: vi.fn(() => "errorsInstrumentation"),
|
|
15
|
+
WebVitalsInstrumentation: vi.fn(() => "webVitalsInstrumentation"),
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
vi.mock("@grafana/faro-web-tracing", () => ({
|
|
20
|
+
TracingInstrumentation: vi.fn(() => "tracingInstrumentation"),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock("@opentelemetry/core", () => ({
|
|
24
|
+
W3CTraceContextPropagator: vi.fn(() => "tracePropagator"),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock("@opentelemetry/instrumentation-document-load", () => ({
|
|
28
|
+
DocumentLoadInstrumentation: vi.fn(() => "docLoadInstrumentation"),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock("@opentelemetry/instrumentation-fetch", () => ({
|
|
32
|
+
FetchInstrumentation: vi.fn((opts) => ({
|
|
33
|
+
...opts,
|
|
34
|
+
name: "fetchInstrumentation",
|
|
35
|
+
})),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock("@opentelemetry/instrumentation-user-interaction", () => ({
|
|
39
|
+
UserInteractionInstrumentation: vi.fn(() => "userInteractionInstrumentation"),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
vi.mock("./internals/hooks", () => ({
|
|
43
|
+
_beforeSend: vi.fn(() => "hooked"),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
describe("buildFaroInstrumentation", () => {
|
|
47
|
+
const validConfig = {
|
|
48
|
+
collectorUrl: "https://valid-url.com",
|
|
49
|
+
serviceName: "test-app",
|
|
50
|
+
detection: {
|
|
51
|
+
email: true,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.clearAllMocks();
|
|
57
|
+
console.warn = vi.fn();
|
|
58
|
+
console.error = vi.fn();
|
|
59
|
+
console.log = vi.fn();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns undefined and warns if config is not provided", () => {
|
|
63
|
+
const result = buildFaroInstrumentation();
|
|
64
|
+
expect(console.warn).toHaveBeenCalledWith(
|
|
65
|
+
"observability config not set. Skipping Faro OpenTelemetry instrumentation.",
|
|
66
|
+
);
|
|
67
|
+
expect(result).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns undefined and warns if collectorUrl is missing", () => {
|
|
71
|
+
const result = buildFaroInstrumentation({
|
|
72
|
+
...validConfig,
|
|
73
|
+
collectorUrl: undefined!,
|
|
74
|
+
});
|
|
75
|
+
expect(console.warn).toHaveBeenCalledWith(
|
|
76
|
+
"collectorUrl not set. Skipping Faro OpenTelemetry instrumentation.",
|
|
77
|
+
);
|
|
78
|
+
expect(result).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns undefined and errors if collectorUrl is invalid", () => {
|
|
82
|
+
const result = buildFaroInstrumentation({
|
|
83
|
+
...validConfig,
|
|
84
|
+
collectorUrl: "not-a-url",
|
|
85
|
+
});
|
|
86
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
87
|
+
"collectorUrl does not use a valid format. Skipping Faro OpenTelemetry instrumentation.",
|
|
88
|
+
);
|
|
89
|
+
expect(result).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("defaults detection.email to true if not provided", () => {
|
|
93
|
+
const config = {
|
|
94
|
+
...validConfig,
|
|
95
|
+
detection: {},
|
|
96
|
+
};
|
|
97
|
+
buildFaroInstrumentation(config);
|
|
98
|
+
expect(initializeFaro).toHaveBeenCalled();
|
|
99
|
+
expect(config.detection.email).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("sets beforeSend if detection.email is true", () => {
|
|
103
|
+
buildFaroInstrumentation({ ...validConfig, detection: { email: true } });
|
|
104
|
+
expect(initializeFaro).toHaveBeenCalledWith(
|
|
105
|
+
expect.objectContaining({
|
|
106
|
+
beforeSend: _beforeSend,
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does not set beforeSend if detection.email is false", () => {
|
|
112
|
+
buildFaroInstrumentation({ ...validConfig, detection: { email: false } });
|
|
113
|
+
expect(initializeFaro).toHaveBeenCalledWith(
|
|
114
|
+
expect.not.objectContaining({
|
|
115
|
+
beforeSend: expect.any(Function),
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("disables batching when collectorMode is 'single'", () => {
|
|
121
|
+
buildFaroInstrumentation({ ...validConfig, collectorMode: "single" });
|
|
122
|
+
expect(initializeFaro).toHaveBeenCalledWith(
|
|
123
|
+
expect.objectContaining({
|
|
124
|
+
batching: {
|
|
125
|
+
enabled: false,
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("sets diagLogLevel if provided", async () => {
|
|
132
|
+
const { DiagLogLevel } = await import("@opentelemetry/api");
|
|
133
|
+
const setLoggerSpy = vi
|
|
134
|
+
.spyOn(await import("@opentelemetry/api"), "diag", "get")
|
|
135
|
+
.mockReturnValue({
|
|
136
|
+
setLogger: vi.fn(),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
buildFaroInstrumentation({ ...validConfig, diagLogLevel: "DEBUG" });
|
|
140
|
+
|
|
141
|
+
expect(setLoggerSpy().setLogger).toHaveBeenCalledWith(
|
|
142
|
+
expect.anything(),
|
|
143
|
+
DiagLogLevel.DEBUG,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("handles initialization failure and logs error", () => {
|
|
148
|
+
(initializeFaro as any).mockImplementationOnce(() => {
|
|
149
|
+
throw new Error("init failed");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const result = buildFaroInstrumentation(validConfig);
|
|
153
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
154
|
+
"Error starting Faro OpenTelemetry instrumentation:",
|
|
155
|
+
expect.any(Error),
|
|
156
|
+
);
|
|
157
|
+
expect(result).toBeUndefined();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns the faro object on successful initialization", () => {
|
|
161
|
+
const result = buildFaroInstrumentation(validConfig);
|
|
162
|
+
expect(console.log).toHaveBeenCalledWith(
|
|
163
|
+
"Faro OpenTelemetry instrumentation started successfully.",
|
|
164
|
+
);
|
|
165
|
+
expect(result).toEqual({ fakeFaro: true });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("sets default detection config if detection is not provided", () => {
|
|
169
|
+
const config = {
|
|
170
|
+
...validConfig,
|
|
171
|
+
detection: undefined,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
buildFaroInstrumentation(config);
|
|
175
|
+
|
|
176
|
+
expect(config.detection).toEqual({ email: true });
|
|
177
|
+
expect(initializeFaro).toHaveBeenCalledWith(
|
|
178
|
+
expect.objectContaining({
|
|
179
|
+
beforeSend: _beforeSend,
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("handles comma-separated corsTraceHeaders", async () => {
|
|
185
|
+
buildFaroInstrumentation({
|
|
186
|
+
...validConfig,
|
|
187
|
+
corsTraceHeaders: "https://site1.com,https://site2.com",
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const { FetchInstrumentation } = await import(
|
|
191
|
+
"@opentelemetry/instrumentation-fetch"
|
|
192
|
+
);
|
|
193
|
+
expect(FetchInstrumentation).toHaveBeenCalledWith(
|
|
194
|
+
expect.objectContaining({
|
|
195
|
+
propagateTraceHeaderCorsUrls: [
|
|
196
|
+
new RegExp("https://site1.com"),
|
|
197
|
+
new RegExp("https://site2.com"),
|
|
198
|
+
],
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
|
|
25
|
+
describe("_beforeSend", () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.clearAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should deeply redact PII in object", () => {
|
|
31
|
+
const item = {
|
|
32
|
+
type: TransportItemType.LOG,
|
|
33
|
+
payload: {
|
|
34
|
+
level: LogLevel.INFO,
|
|
35
|
+
context: {},
|
|
36
|
+
timestamp: Date.now().toString(),
|
|
37
|
+
message: "my email is bob@abc.com",
|
|
38
|
+
},
|
|
39
|
+
meta: {
|
|
40
|
+
user: {
|
|
41
|
+
email: "carol@abc.com",
|
|
42
|
+
attributes: {
|
|
43
|
+
alternative_email: "foo@xyz.org",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
} satisfies TransportItem<LogEvent>;
|
|
48
|
+
|
|
49
|
+
const redacted: TransportItem<LogEvent> = _beforeSend(
|
|
50
|
+
item,
|
|
51
|
+
) as TransportItem<LogEvent>;
|
|
52
|
+
|
|
53
|
+
expect(redacted.payload.message).toBe("my email is [REDACTED EMAIL]");
|
|
54
|
+
expect(redacted.meta.user).not.toBeNull();
|
|
55
|
+
expect(redacted.meta.user!.email).toBe("[REDACTED EMAIL]");
|
|
56
|
+
expect(redacted.meta.user!.attributes).toBeDefined();
|
|
57
|
+
expect(redacted.meta.user!.attributes!["alternative_email"]).toBe(
|
|
58
|
+
"[REDACTED EMAIL]",
|
|
59
|
+
);
|
|
60
|
+
expect(faro.api.pushMeasurement).toHaveBeenCalledTimes(3);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should redact email inside arrays of primitives", () => {
|
|
64
|
+
const item: TransportItem<LogEvent> = {
|
|
65
|
+
type: TransportItemType.LOG,
|
|
66
|
+
payload: {
|
|
67
|
+
message: "start",
|
|
68
|
+
context: {
|
|
69
|
+
messages: ["hello", "email: test@abc.com", "ok"],
|
|
70
|
+
},
|
|
71
|
+
timestamp: "123",
|
|
72
|
+
level: LogLevel.INFO,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const redacted = _beforeSend(item)!;
|
|
77
|
+
|
|
78
|
+
expect(redacted.payload.context.messages[1]).toBe(
|
|
79
|
+
"email: [REDACTED EMAIL]",
|
|
80
|
+
);
|
|
81
|
+
expect(faro.api.pushMeasurement).toHaveBeenCalledOnce();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should redact emails in arrays of objects", () => {
|
|
85
|
+
const item: TransportItem<LogEvent> = {
|
|
86
|
+
type: TransportItemType.LOG,
|
|
87
|
+
payload: {
|
|
88
|
+
message: "checking",
|
|
89
|
+
context: {
|
|
90
|
+
events: [
|
|
91
|
+
{ message: "contact a@b.com" },
|
|
92
|
+
{ message: "nothing here" },
|
|
93
|
+
{ note: "try c@d.net" },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
timestamp: "123",
|
|
97
|
+
level: LogLevel.INFO,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const redacted = _beforeSend(item)!;
|
|
102
|
+
|
|
103
|
+
expect(redacted.payload.context.events[0].message).toBe(
|
|
104
|
+
"contact [REDACTED EMAIL]",
|
|
105
|
+
);
|
|
106
|
+
expect(redacted.payload.context.events[2].note).toBe(
|
|
107
|
+
"try [REDACTED EMAIL]",
|
|
108
|
+
);
|
|
109
|
+
expect(faro.api.pushMeasurement).toHaveBeenCalledTimes(2);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should not break on nulls, undefined, numbers, booleans", () => {
|
|
113
|
+
const item: TransportItem<LogEvent> = {
|
|
114
|
+
type: TransportItemType.LOG,
|
|
115
|
+
payload: {
|
|
116
|
+
message: null as any,
|
|
117
|
+
count: 123,
|
|
118
|
+
active: true,
|
|
119
|
+
description: undefined,
|
|
120
|
+
} as any,
|
|
121
|
+
meta: null,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const redacted = _beforeSend(item)!;
|
|
125
|
+
|
|
126
|
+
expect(redacted.payload.message).toBeNull();
|
|
127
|
+
expect(redacted.payload.count).toBe(123);
|
|
128
|
+
expect(redacted.payload.active).toBe(true);
|
|
129
|
+
expect("description" in redacted.payload).toBe(true); // still defined as undefined
|
|
130
|
+
expect(faro.api.pushMeasurement).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("should decode and redact encoded emails", () => {
|
|
134
|
+
const item: TransportItem<LogEvent> = {
|
|
135
|
+
type: TransportItemType.LOG,
|
|
136
|
+
payload: {
|
|
137
|
+
message: "contact%20user%40domain.com",
|
|
138
|
+
level: LogLevel.INFO,
|
|
139
|
+
context: {},
|
|
140
|
+
timestamp: "now",
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const redacted = _beforeSend(item)!;
|
|
145
|
+
|
|
146
|
+
expect(redacted.payload.message).toBe("contact [REDACTED EMAIL]");
|
|
147
|
+
expect(faro.api.pushMeasurement).toHaveBeenCalledOnce();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { TransportItemType } from "@grafana/faro-web-sdk";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { _cleanStringPII } from "../../lib/internals/pii-detection";
|
|
4
|
+
|
|
5
|
+
// Mock faro.api.pushMeasurement
|
|
6
|
+
vi.mock("@grafana/faro-web-sdk", async () => {
|
|
7
|
+
const actual = await vi.importActual<any>("@grafana/faro-web-sdk");
|
|
8
|
+
return {
|
|
9
|
+
...actual,
|
|
10
|
+
faro: {
|
|
11
|
+
api: {
|
|
12
|
+
pushMeasurement: vi.fn(),
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
import { faro } from "@grafana/faro-web-sdk";
|
|
19
|
+
|
|
20
|
+
describe("_cleanStringPII", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should redact email and call pushMeasurement", () => {
|
|
26
|
+
const input = "Contact me at alice@example.com.";
|
|
27
|
+
const result = _cleanStringPII(input, TransportItemType.LOG);
|
|
28
|
+
|
|
29
|
+
expect(result).toBe("Contact me at [REDACTED EMAIL].");
|
|
30
|
+
expect(faro.api.pushMeasurement).toHaveBeenCalledOnce();
|
|
31
|
+
expect(faro.api.pushMeasurement).toHaveBeenCalledWith(
|
|
32
|
+
{
|
|
33
|
+
type: "faro_o11y_pii_redaction",
|
|
34
|
+
values: { redacted: 1 },
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
context: {
|
|
38
|
+
pii_type: "email",
|
|
39
|
+
redaction_source: TransportItemType.LOG,
|
|
40
|
+
pii_email_domain: "example.com",
|
|
41
|
+
pii_format: "string",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should decode and redact URI-encoded emails", () => {
|
|
48
|
+
const encoded = "mailto%3Ajohn.doe%40gmail.com";
|
|
49
|
+
const result = _cleanStringPII(encoded, TransportItemType.EXCEPTION);
|
|
50
|
+
|
|
51
|
+
expect(result).toBe("mailto:[REDACTED EMAIL]");
|
|
52
|
+
expect(faro.api.pushMeasurement).toHaveBeenCalledOnce();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should return same string when no email present", () => {
|
|
56
|
+
const input = "Just a normal message.";
|
|
57
|
+
const result = _cleanStringPII(input, TransportItemType.LOG);
|
|
58
|
+
|
|
59
|
+
expect(result).toBe(input);
|
|
60
|
+
expect(faro.api.pushMeasurement).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should return same string when decodeURIComponent fail", () => {
|
|
64
|
+
const input = "mailto%john.doe%4gmail.com";
|
|
65
|
+
const result = _cleanStringPII(input, TransportItemType.LOG);
|
|
66
|
+
|
|
67
|
+
expect(result).toBe(input);
|
|
68
|
+
expect(faro.api.pushMeasurement).not.toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
});
|
package/vitest.config.ts
CHANGED
package/test/index.test.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect, vi } from "vitest";
|
|
2
|
-
import { FaroSDKConfig } from "../lib";
|
|
3
|
-
import { instrumentFaro } from "../index";
|
|
4
|
-
|
|
5
|
-
vi.mock("../lib/instrumentation.faro", () => ({
|
|
6
|
-
default: vi.fn(),
|
|
7
|
-
}));
|
|
8
|
-
|
|
9
|
-
describe("instrumentFaro", () => {
|
|
10
|
-
test("should call buildFaroInstrumentation with the provided config", async () => {
|
|
11
|
-
const config: FaroSDKConfig = {
|
|
12
|
-
serviceName: "custom-service",
|
|
13
|
-
collectorUrl: "http://custom-collector.com",
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
const buildFaroInstrumentation = await import(
|
|
17
|
-
"../lib/instrumentation.faro"
|
|
18
|
-
);
|
|
19
|
-
|
|
20
|
-
instrumentFaro(config);
|
|
21
|
-
|
|
22
|
-
expect(buildFaroInstrumentation.default).toHaveBeenCalledWith(config);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test("should not throw when called without arguments", () => {
|
|
26
|
-
expect(() => instrumentFaro()).not.toThrow();
|
|
27
|
-
});
|
|
28
|
-
});
|