@ogcio/o11y-sdk-node 0.1.0-beta.9 → 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 +128 -0
- package/README.md +158 -13
- package/dist/lib/config-manager.d.ts +3 -0
- package/dist/lib/config-manager.js +11 -0
- package/dist/lib/exporter/console.js +3 -4
- package/dist/lib/exporter/grpc.d.ts +1 -1
- package/dist/lib/exporter/grpc.js +24 -14
- package/dist/lib/exporter/http.d.ts +1 -1
- package/dist/lib/exporter/http.js +14 -13
- package/dist/lib/exporter/pii-exporter-decorator.d.ts +20 -0
- package/dist/lib/exporter/pii-exporter-decorator.js +103 -0
- package/dist/lib/exporter/processor-config.d.ts +5 -0
- package/dist/lib/exporter/processor-config.js +16 -0
- package/dist/lib/index.d.ts +25 -4
- package/dist/lib/instrumentation.node.d.ts +1 -1
- package/dist/lib/instrumentation.node.js +29 -19
- package/dist/lib/internals/hooks.d.ts +3 -0
- package/dist/lib/internals/hooks.js +12 -0
- package/dist/lib/internals/pii-detection.d.ts +17 -0
- package/dist/lib/internals/pii-detection.js +116 -0
- package/dist/lib/internals/shared-metrics.d.ts +7 -0
- package/dist/lib/internals/shared-metrics.js +18 -0
- package/dist/lib/resource.js +2 -2
- package/dist/lib/traces.d.ts +20 -1
- package/dist/lib/traces.js +47 -1
- package/dist/package.json +23 -21
- package/dist/vitest.config.js +8 -2
- package/lib/config-manager.ts +16 -0
- package/lib/exporter/console.ts +6 -4
- package/lib/exporter/grpc.ts +46 -20
- package/lib/exporter/http.ts +33 -20
- package/lib/exporter/pii-exporter-decorator.ts +152 -0
- package/lib/exporter/processor-config.ts +23 -0
- package/lib/index.ts +28 -4
- package/lib/instrumentation.node.ts +37 -22
- package/lib/internals/hooks.ts +14 -0
- package/lib/internals/pii-detection.ts +145 -0
- package/lib/internals/shared-metrics.ts +34 -0
- package/lib/resource.ts +3 -2
- package/lib/traces.ts +74 -1
- package/package.json +23 -21
- package/test/config-manager.test.ts +34 -0
- package/test/exporter/pii-exporter-decorator.test.ts +139 -0
- package/test/index.test.ts +44 -12
- package/test/integration/README.md +1 -1
- package/test/integration/{integration.test.ts → http-tracing.integration.test.ts} +0 -2
- package/test/integration/pii.integration.test.ts +68 -0
- package/test/integration/run.sh +2 -2
- package/test/internals/hooks.test.ts +45 -0
- package/test/internals/pii-detection.test.ts +141 -0
- package/test/internals/shared-metrics.test.ts +34 -0
- package/test/node-config.test.ts +68 -30
- package/test/processor/enrich-span-processor.test.ts +2 -54
- package/test/resource.test.ts +12 -1
- package/test/traces/active-span.test.ts +28 -0
- package/test/traces/with-span.test.ts +340 -0
- package/test/utils/alloy-log-parser.ts +7 -0
- package/test/utils/mock-signals.ts +144 -0
- package/test/validation.test.ts +22 -16
- package/vitest.config.ts +8 -2
package/lib/traces.ts
CHANGED
|
@@ -1,5 +1,78 @@
|
|
|
1
|
-
import { trace } from "@opentelemetry/api";
|
|
1
|
+
import { Span, SpanOptions, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
2
|
+
import { getNodeSdkConfig } from "./config-manager.js";
|
|
2
3
|
|
|
4
|
+
export type WithSpanParams<T> = {
|
|
5
|
+
/**
|
|
6
|
+
* The name of the trace the span should belong to.
|
|
7
|
+
* NOTE: If you want the new span to belong to an already existing trace, you should provide the same tracer name
|
|
8
|
+
*/
|
|
9
|
+
traceName?: string;
|
|
10
|
+
spanName: string;
|
|
11
|
+
spanOptions?: SpanOptions;
|
|
12
|
+
/** A function defining the task you want to be wrapped by this span */
|
|
13
|
+
fn: (span: Span) => T | Promise<T>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Generates a function wrapping a given Callable `fn` into an error handling block.
|
|
18
|
+
* Setting Span status and recording any caught exception before bubbling it up.
|
|
19
|
+
*
|
|
20
|
+
* Marks the span as ended once the provided callable has ended or an error has been caught.
|
|
21
|
+
*
|
|
22
|
+
* @returns {Promise<T>} where T is the type returned by the Callable.
|
|
23
|
+
* @throws any error thrown by the original Callable `fn` provided.
|
|
24
|
+
*/
|
|
25
|
+
function selfContainedSpanHandlerGenerator<T>(
|
|
26
|
+
fn: (span: Span) => T | Promise<T>,
|
|
27
|
+
): (span: Span) => Promise<T> {
|
|
28
|
+
return async (span: Span) => {
|
|
29
|
+
try {
|
|
30
|
+
const fnResult = await fn(span);
|
|
31
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
32
|
+
return fnResult;
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (err instanceof Error) {
|
|
35
|
+
span.recordException(err);
|
|
36
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
span.recordException({ message: JSON.stringify(err) });
|
|
41
|
+
span.setStatus({
|
|
42
|
+
code: SpanStatusCode.ERROR,
|
|
43
|
+
message: JSON.stringify(err),
|
|
44
|
+
});
|
|
45
|
+
throw err;
|
|
46
|
+
} finally {
|
|
47
|
+
span.end();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Gets the currently active OpenTelemetry span.
|
|
54
|
+
*
|
|
55
|
+
* @returns {Span | undefined} The active span with redaction logic applied,
|
|
56
|
+
* or `undefined` if there is no active span in context.
|
|
57
|
+
*/
|
|
3
58
|
export function getActiveSpan() {
|
|
4
59
|
return trace.getActiveSpan();
|
|
5
60
|
}
|
|
61
|
+
|
|
62
|
+
export function withSpan<T>({
|
|
63
|
+
traceName,
|
|
64
|
+
spanName,
|
|
65
|
+
spanOptions = {},
|
|
66
|
+
fn,
|
|
67
|
+
}: WithSpanParams<T>) {
|
|
68
|
+
const sdkConfig = getNodeSdkConfig();
|
|
69
|
+
const tracer = trace.getTracer(
|
|
70
|
+
traceName ?? sdkConfig.serviceName ?? "o11y-sdk",
|
|
71
|
+
sdkConfig.serviceVersion,
|
|
72
|
+
);
|
|
73
|
+
return tracer.startActiveSpan(
|
|
74
|
+
spanName,
|
|
75
|
+
spanOptions,
|
|
76
|
+
selfContainedSpanHandlerGenerator<T>(fn),
|
|
77
|
+
);
|
|
78
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ogcio/o11y-sdk-node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Opentelemetry standard instrumentation SDK for NodeJS based project",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -19,29 +19,31 @@
|
|
|
19
19
|
"author": "team:ogcio/observability",
|
|
20
20
|
"license": "ISC",
|
|
21
21
|
"dependencies": {
|
|
22
|
+
"@grpc/grpc-js": "^1.13.4",
|
|
22
23
|
"@opentelemetry/api": "^1.9.0",
|
|
23
|
-
"@opentelemetry/
|
|
24
|
-
"@opentelemetry/
|
|
25
|
-
"@opentelemetry/
|
|
26
|
-
"@opentelemetry/exporter-logs-otlp-
|
|
27
|
-
"@opentelemetry/exporter-
|
|
28
|
-
"@opentelemetry/exporter-metrics-otlp-
|
|
29
|
-
"@opentelemetry/exporter-
|
|
30
|
-
"@opentelemetry/exporter-trace-otlp-
|
|
31
|
-
"@opentelemetry/
|
|
32
|
-
"@opentelemetry/
|
|
33
|
-
"@opentelemetry/
|
|
34
|
-
"@opentelemetry/
|
|
35
|
-
"@opentelemetry/sdk-
|
|
36
|
-
"@opentelemetry/sdk-
|
|
37
|
-
"@opentelemetry/sdk-
|
|
24
|
+
"@opentelemetry/api-logs": "^0.203.0",
|
|
25
|
+
"@opentelemetry/auto-instrumentations-node": "^0.60.1",
|
|
26
|
+
"@opentelemetry/core": "^2.0.1",
|
|
27
|
+
"@opentelemetry/exporter-logs-otlp-grpc": "^0.202.0",
|
|
28
|
+
"@opentelemetry/exporter-logs-otlp-http": "^0.202.0",
|
|
29
|
+
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.202.0",
|
|
30
|
+
"@opentelemetry/exporter-metrics-otlp-http": "^0.202.0",
|
|
31
|
+
"@opentelemetry/exporter-trace-otlp-grpc": "^0.202.0",
|
|
32
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.202.0",
|
|
33
|
+
"@opentelemetry/instrumentation": "^0.202.0",
|
|
34
|
+
"@opentelemetry/otlp-exporter-base": "^0.202.0",
|
|
35
|
+
"@opentelemetry/resources": "^2.0.1",
|
|
36
|
+
"@opentelemetry/sdk-logs": "^0.202.0",
|
|
37
|
+
"@opentelemetry/sdk-metrics": "^2.0.1",
|
|
38
|
+
"@opentelemetry/sdk-node": "^0.202.0",
|
|
39
|
+
"@opentelemetry/sdk-trace-base": "^2.0.1"
|
|
38
40
|
},
|
|
39
41
|
"devDependencies": {
|
|
40
|
-
"@types/node": "^
|
|
41
|
-
"@vitest/coverage-v8": "^3.
|
|
42
|
-
"tsx": "^4.
|
|
43
|
-
"typescript": "^5.8.
|
|
44
|
-
"vitest": "^3.
|
|
42
|
+
"@types/node": "^24.0.10",
|
|
43
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
44
|
+
"tsx": "^4.20.3",
|
|
45
|
+
"typescript": "^5.8.3",
|
|
46
|
+
"vitest": "^3.2.4"
|
|
45
47
|
},
|
|
46
48
|
"engines": {
|
|
47
49
|
"node": ">=20.6.0"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { getNodeSdkConfig, setNodeSdkConfig } from "../lib/config-manager";
|
|
3
|
+
import { NodeSDKConfig } from "../lib";
|
|
4
|
+
|
|
5
|
+
describe("Config Manager", () => {
|
|
6
|
+
it("throws if getConfig is called before initialization", () => {
|
|
7
|
+
expect(() => getNodeSdkConfig()).toThrow();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("sdk defined config is not pollutable", () => {
|
|
11
|
+
const config: NodeSDKConfig = {
|
|
12
|
+
collectorUrl: "http://example.com",
|
|
13
|
+
serviceName: "MyService",
|
|
14
|
+
spanAttributes: {
|
|
15
|
+
"my.attribute": "value",
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
setNodeSdkConfig(config);
|
|
20
|
+
|
|
21
|
+
const cfg = getNodeSdkConfig();
|
|
22
|
+
|
|
23
|
+
// Top level
|
|
24
|
+
cfg.collectorUrl = "http://example.com/changed";
|
|
25
|
+
// Subfield
|
|
26
|
+
cfg.spanAttributes["my.attribute"] = "another-attribute";
|
|
27
|
+
|
|
28
|
+
// Ensure config values remain unchanged
|
|
29
|
+
expect(getNodeSdkConfig().collectorUrl).toStrictEqual(config.collectorUrl);
|
|
30
|
+
expect(getNodeSdkConfig().spanAttributes["my.attribute"]).toStrictEqual(
|
|
31
|
+
config.spanAttributes["my.attribute"],
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { ReadableLogRecord } from "@opentelemetry/sdk-logs";
|
|
2
|
+
import type { ReadableSpan } from "@opentelemetry/sdk-trace-base";
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import type { NodeSDKConfig } from "../../index.js";
|
|
5
|
+
import { PIIExporterDecorator } from "../../lib/exporter/pii-exporter-decorator.js";
|
|
6
|
+
import * as pii from "../../lib/internals/pii-detection.js";
|
|
7
|
+
|
|
8
|
+
const mockExporter = {
|
|
9
|
+
export: vi.fn(),
|
|
10
|
+
forceFlush: vi.fn().mockResolvedValue(undefined),
|
|
11
|
+
shutdown: vi.fn().mockResolvedValue(undefined),
|
|
12
|
+
_delegate: {}, // required by OTLPExporterBase
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const mockCallback = vi.fn();
|
|
16
|
+
|
|
17
|
+
const baseSpan = (): ReadableSpan =>
|
|
18
|
+
({
|
|
19
|
+
name: "john.doe@example.com",
|
|
20
|
+
kind: 1,
|
|
21
|
+
spanContext: () => ({ traceId: "abc", spanId: "def" }),
|
|
22
|
+
attributes: { email: "john.doe@example.com" },
|
|
23
|
+
resource: {
|
|
24
|
+
attributes: { team: "dev@example.com" },
|
|
25
|
+
otherProp: "shouldBeLost",
|
|
26
|
+
},
|
|
27
|
+
links: [{ attributes: { collaborator: "co@example.com" } }],
|
|
28
|
+
events: [{ name: "event@email.com", attributes: { code: "123" } }],
|
|
29
|
+
}) as any;
|
|
30
|
+
|
|
31
|
+
const baseLog = (): ReadableLogRecord =>
|
|
32
|
+
({
|
|
33
|
+
body: "user: john.doe@example.com",
|
|
34
|
+
attributes: { email: "john.doe@example.com" },
|
|
35
|
+
resource: {
|
|
36
|
+
attributes: { service: "log@example.com" },
|
|
37
|
+
otherProp: "shouldBeLost",
|
|
38
|
+
},
|
|
39
|
+
severityText: "INFO",
|
|
40
|
+
severityNumber: 1,
|
|
41
|
+
}) as any;
|
|
42
|
+
|
|
43
|
+
vi.mock("../../lib/internals/pii-detection.js", async () => {
|
|
44
|
+
return {
|
|
45
|
+
_cleanStringPII: vi.fn((v: string) => `[REDACTED(${v})]`),
|
|
46
|
+
_cleanObjectPII: vi.fn((obj: any) =>
|
|
47
|
+
Object.fromEntries(
|
|
48
|
+
Object.entries(obj || {}).map(([k, v]) => [k, `[REDACTED(${v})]`]),
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
_cleanLogBodyPII: vi.fn((v: any) => `[REDACTED_BODY(${v})]`),
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("PIIExporterDecorator", () => {
|
|
56
|
+
let decorator: PIIExporterDecorator;
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
vi.clearAllMocks();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("delegates forceFlush and shutdown", async () => {
|
|
63
|
+
const config: NodeSDKConfig = {};
|
|
64
|
+
decorator = new PIIExporterDecorator(mockExporter as any, config);
|
|
65
|
+
|
|
66
|
+
await decorator.forceFlush();
|
|
67
|
+
await decorator.shutdown();
|
|
68
|
+
|
|
69
|
+
expect(mockExporter.forceFlush).toHaveBeenCalled();
|
|
70
|
+
expect(mockExporter.shutdown).toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("skips redaction if detection.email is false", () => {
|
|
74
|
+
const config: NodeSDKConfig = { detection: { email: false } };
|
|
75
|
+
decorator = new PIIExporterDecorator(mockExporter as any, config);
|
|
76
|
+
|
|
77
|
+
const spans = [baseSpan()];
|
|
78
|
+
decorator.export(spans, mockCallback);
|
|
79
|
+
|
|
80
|
+
expect(mockExporter.export).toHaveBeenCalledWith(spans, mockCallback);
|
|
81
|
+
expect(pii._cleanStringPII).not.toHaveBeenCalled();
|
|
82
|
+
expect(pii._cleanObjectPII).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("redacts span data if detection.email is true", () => {
|
|
86
|
+
const config: NodeSDKConfig = { detection: { email: true } };
|
|
87
|
+
decorator = new PIIExporterDecorator(mockExporter as any, config);
|
|
88
|
+
|
|
89
|
+
const span = baseSpan();
|
|
90
|
+
decorator.export([span], mockCallback);
|
|
91
|
+
|
|
92
|
+
expect(pii._cleanStringPII).toHaveBeenCalled();
|
|
93
|
+
expect(pii._cleanObjectPII).toHaveBeenCalled();
|
|
94
|
+
expect(mockExporter.export).toHaveBeenCalledWith([span], mockCallback);
|
|
95
|
+
|
|
96
|
+
// verify name and nested structure were touched
|
|
97
|
+
expect(span.name).toMatch(/^\[REDACTED\(/);
|
|
98
|
+
expect(span.attributes.email).toMatch(/^\[REDACTED\(/);
|
|
99
|
+
|
|
100
|
+
// Resource is replaced with only attributes, old props lost
|
|
101
|
+
expect(span.resource).toEqual({
|
|
102
|
+
attributes: { team: "[REDACTED(dev@example.com)]" },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(span.events[0].name).toMatch(/^\[REDACTED\(/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("redacts log record data if detection.email is true", () => {
|
|
109
|
+
const config: NodeSDKConfig = { detection: { email: true } };
|
|
110
|
+
decorator = new PIIExporterDecorator(mockExporter as any, config);
|
|
111
|
+
|
|
112
|
+
const log = baseLog();
|
|
113
|
+
decorator.export([log], mockCallback);
|
|
114
|
+
|
|
115
|
+
expect(pii._cleanLogBodyPII).toHaveBeenCalled();
|
|
116
|
+
expect(pii._cleanObjectPII).toHaveBeenCalled();
|
|
117
|
+
|
|
118
|
+
expect(log.body).toMatch(/^\[REDACTED_BODY\(/);
|
|
119
|
+
expect(log.attributes.email).toMatch(/^\[REDACTED\(/);
|
|
120
|
+
|
|
121
|
+
// Resource replaced with only attributes, other props lost
|
|
122
|
+
expect(log.resource).toEqual({
|
|
123
|
+
attributes: { service: "[REDACTED(log@example.com)]" },
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("handles a mix of spans and logs", () => {
|
|
128
|
+
const config: NodeSDKConfig = { detection: { email: true } };
|
|
129
|
+
decorator = new PIIExporterDecorator(mockExporter as any, config);
|
|
130
|
+
|
|
131
|
+
const items = [baseSpan(), baseLog()];
|
|
132
|
+
decorator.export(items, mockCallback);
|
|
133
|
+
|
|
134
|
+
expect(mockExporter.export).toHaveBeenCalledWith(items, mockCallback);
|
|
135
|
+
expect(pii._cleanLogBodyPII).toHaveBeenCalled();
|
|
136
|
+
expect(pii._cleanStringPII).toHaveBeenCalled();
|
|
137
|
+
expect(pii._cleanObjectPII).toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
});
|
package/test/index.test.ts
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
|
-
import { describe, test, expect, vi } from "vitest";
|
|
1
|
+
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { NodeSDKConfig } from "../index";
|
|
3
3
|
import { instrumentNode } from "../index";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
default: vi.fn(),
|
|
7
|
-
}));
|
|
4
|
+
import * as buildNodeInstrumentationModule from "../lib/instrumentation.node";
|
|
5
|
+
import { metrics } from "@opentelemetry/sdk-node";
|
|
8
6
|
|
|
9
7
|
describe("instrumentNode", () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
// @ts-ignore Avoid actually running exporters at any time in tests (overriding private method)
|
|
10
|
+
vi.spyOn(
|
|
11
|
+
metrics.PeriodicExportingMetricReader.prototype,
|
|
12
|
+
"_doRun",
|
|
13
|
+
).mockImplementation(vi.fn());
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.restoreAllMocks();
|
|
18
|
+
});
|
|
19
|
+
|
|
10
20
|
test("should call buildNodeInstrumentation with the provided config", async () => {
|
|
21
|
+
const instrumentationMock = vi
|
|
22
|
+
.spyOn(buildNodeInstrumentationModule, "default")
|
|
23
|
+
.mockImplementation(vi.fn());
|
|
24
|
+
|
|
11
25
|
const config: NodeSDKConfig = {
|
|
12
26
|
serviceName: "custom-service",
|
|
13
27
|
collectorUrl: "http://custom-collector.com",
|
|
@@ -23,16 +37,34 @@ describe("instrumentNode", () => {
|
|
|
23
37
|
},
|
|
24
38
|
};
|
|
25
39
|
|
|
26
|
-
|
|
27
|
-
"../lib/instrumentation.node"
|
|
28
|
-
);
|
|
40
|
+
await instrumentNode(config);
|
|
29
41
|
|
|
30
|
-
|
|
42
|
+
expect(instrumentationMock).toHaveBeenCalledWith(config);
|
|
43
|
+
});
|
|
31
44
|
|
|
32
|
-
|
|
45
|
+
test("should not throw when called without arguments", async () => {
|
|
46
|
+
await expect(instrumentNode()).resolves.not.toThrow();
|
|
33
47
|
});
|
|
34
48
|
|
|
35
|
-
test("should
|
|
36
|
-
|
|
49
|
+
test("should invoke instrumentation shutdown on SIGTERM", async () => {
|
|
50
|
+
const config: NodeSDKConfig = {
|
|
51
|
+
serviceName: "custom-service",
|
|
52
|
+
collectorUrl: "http://custom-collector.com",
|
|
53
|
+
protocol: "grpc",
|
|
54
|
+
resourceAttributes: {
|
|
55
|
+
"team.infra.cluster": "dev-01",
|
|
56
|
+
"team.infra.pod": "01",
|
|
57
|
+
"team.service.type": "fastify",
|
|
58
|
+
},
|
|
59
|
+
spanAttributes: {
|
|
60
|
+
"signal.namespace": "example",
|
|
61
|
+
"signal.number": () => "callback",
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const sdk = await instrumentNode(config);
|
|
66
|
+
const shutdownMock = vi.spyOn(sdk, "shutdown");
|
|
67
|
+
process.emit("SIGTERM");
|
|
68
|
+
expect(shutdownMock).toHaveBeenCalled();
|
|
37
69
|
});
|
|
38
70
|
});
|
|
@@ -22,5 +22,5 @@ The `run.sh` script performs the following steps:
|
|
|
22
22
|
- run fastify app in a docker container
|
|
23
23
|
- ensure is running otherwise exit process
|
|
24
24
|
- execute some curl to the fastify microservice
|
|
25
|
-
-
|
|
25
|
+
- persist alloy log to a file and save to following path `/packages/sdk-node/test/integration/`
|
|
26
26
|
- docker turn down process (containers/network/image)
|
|
@@ -10,8 +10,6 @@ describe("instrumentation integration test", () => {
|
|
|
10
10
|
let health_traces_counter = 0;
|
|
11
11
|
let dummy_traces_counter = 0;
|
|
12
12
|
|
|
13
|
-
console.log(data.split(/\nts=/).length);
|
|
14
|
-
|
|
15
13
|
for (const line of data.split(/\nts=/)) {
|
|
16
14
|
const parsedLine: Record<string, object | string | number> =
|
|
17
15
|
parseLog(line);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, test, assert } from "vitest";
|
|
2
|
+
import { parseLog } from "../utils/alloy-log-parser";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
describe("pii integration test", () => {
|
|
7
|
+
test("should check redacted attributes and detect zero emails in logs", async () => {
|
|
8
|
+
const data = await readFile(join(__dirname, "logs.txt"), "utf-8");
|
|
9
|
+
|
|
10
|
+
let health_traces_counter = 0;
|
|
11
|
+
let dummy_traces_counter = 0;
|
|
12
|
+
let email_not_redacted = 0;
|
|
13
|
+
|
|
14
|
+
for (const line of data.split(/\nts=/)) {
|
|
15
|
+
const parsedLine: Record<string, object | string | number> =
|
|
16
|
+
parseLog(line);
|
|
17
|
+
|
|
18
|
+
if (
|
|
19
|
+
parsedLine["attributes"] &&
|
|
20
|
+
parsedLine["attributes"]["span_kind"] &&
|
|
21
|
+
parsedLine["attributes"]["span_kind"] === "log"
|
|
22
|
+
) {
|
|
23
|
+
const matches = (parsedLine["log_body"] as string).match(
|
|
24
|
+
/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
|
|
25
|
+
);
|
|
26
|
+
if (matches) {
|
|
27
|
+
email_not_redacted++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (
|
|
32
|
+
parsedLine["attributes"] &&
|
|
33
|
+
parsedLine["attributes"]["span_kind"] &&
|
|
34
|
+
parsedLine["attributes"]["span_kind"] === "trace"
|
|
35
|
+
) {
|
|
36
|
+
if (parsedLine["attributes"]["http.target"] === "/api/dummy") {
|
|
37
|
+
dummy_traces_counter++;
|
|
38
|
+
|
|
39
|
+
// verify global sdk span resource
|
|
40
|
+
assert.equal(
|
|
41
|
+
parsedLine["resource_attributes"]["email"],
|
|
42
|
+
"[REDACTED EMAIL]",
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// verify global sdk span attributes
|
|
46
|
+
assert.equal(parsedLine["attributes"]["email"], "[REDACTED EMAIL]");
|
|
47
|
+
// verify custom runtime span attributes
|
|
48
|
+
assert.equal(
|
|
49
|
+
parsedLine["attributes"]["multiple.email"],
|
|
50
|
+
"[REDACTED EMAIL]",
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// verify global sdk span attributes
|
|
54
|
+
assert.equal(parsedLine["events"]["email"], "[REDACTED EMAIL]");
|
|
55
|
+
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (parsedLine["attributes"]["http.target"] === "/api/health") {
|
|
59
|
+
health_traces_counter++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
assert.equal(email_not_redacted, 0);
|
|
65
|
+
assert.equal(health_traces_counter, 0);
|
|
66
|
+
assert.equal(dummy_traces_counter, 2);
|
|
67
|
+
});
|
|
68
|
+
});
|
package/test/integration/run.sh
CHANGED
|
@@ -16,7 +16,7 @@ docker run -d \
|
|
|
16
16
|
--name $ALLOY_CONTAINER_NAME \
|
|
17
17
|
-p 4317:4317 \
|
|
18
18
|
-p 4318:4318 \
|
|
19
|
-
grafana/alloy \
|
|
19
|
+
grafana/alloy:v1.9.2 \
|
|
20
20
|
run --server.http.listen-addr=0.0.0.0:12345 --stability.level=experimental /etc/alloy/config.alloy
|
|
21
21
|
|
|
22
22
|
MAX_RETRIES=10
|
|
@@ -69,7 +69,7 @@ if [[ $ERROR_CODE -eq 0 ]]; then
|
|
|
69
69
|
fi
|
|
70
70
|
|
|
71
71
|
# sleep N seconds to await instrumentation flow send and receiving signals
|
|
72
|
-
sleep
|
|
72
|
+
sleep 1
|
|
73
73
|
|
|
74
74
|
# Copy logs from container to file
|
|
75
75
|
docker container logs $ALLOY_CONTAINER_NAME >&$ROOT_PATH/packages/sdk-node/test/integration/logs.txt
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { _shutdownHook } from "../../lib/internals/hooks";
|
|
3
|
+
|
|
4
|
+
describe("shutdown hook", () => {
|
|
5
|
+
it("should call sdk.shutdown and log success on SIGTERM", async () => {
|
|
6
|
+
const shutdownMock = vi.fn().mockResolvedValue(undefined);
|
|
7
|
+
const consoleLogMock = vi
|
|
8
|
+
.spyOn(console, "log")
|
|
9
|
+
.mockImplementation(() => {});
|
|
10
|
+
|
|
11
|
+
_shutdownHook({ shutdown: shutdownMock });
|
|
12
|
+
|
|
13
|
+
process.emit("SIGTERM");
|
|
14
|
+
// Wait for async logic
|
|
15
|
+
await Promise.resolve();
|
|
16
|
+
|
|
17
|
+
expect(shutdownMock).toHaveBeenCalled();
|
|
18
|
+
expect(consoleLogMock).toHaveBeenCalledWith(
|
|
19
|
+
"NodeJS OpenTelemetry instrumentation shutdown successfully",
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
consoleLogMock.mockRestore();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should catch and log errors on shutdown failure", async () => {
|
|
26
|
+
const error = new Error("Shutdown failed");
|
|
27
|
+
const shutdownMock = vi.fn().mockRejectedValue(error);
|
|
28
|
+
const consoleErrorMock = vi
|
|
29
|
+
.spyOn(console, "error")
|
|
30
|
+
.mockImplementation(() => {});
|
|
31
|
+
|
|
32
|
+
_shutdownHook({ shutdown: shutdownMock });
|
|
33
|
+
|
|
34
|
+
process.emit("SIGTERM");
|
|
35
|
+
// Wait for async logic
|
|
36
|
+
await Promise.resolve();
|
|
37
|
+
|
|
38
|
+
expect(consoleErrorMock).toHaveBeenCalledWith(
|
|
39
|
+
"Error shutting down NodeJS OpenTelemetry instrumentation:",
|
|
40
|
+
error,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
consoleErrorMock.mockRestore();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
_cleanStringPII,
|
|
4
|
+
_cleanLogBodyPII,
|
|
5
|
+
} from "../../lib/internals/pii-detection.js";
|
|
6
|
+
import * as sharedMetrics from "../../lib/internals/shared-metrics.js";
|
|
7
|
+
|
|
8
|
+
describe("PII Detection Utils", () => {
|
|
9
|
+
const mockMetricAdd = vi.fn();
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.restoreAllMocks();
|
|
13
|
+
vi.spyOn(sharedMetrics, "_getPIICounterRedactionMetric").mockReturnValue({
|
|
14
|
+
add: mockMetricAdd,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("_cleanStringPII", () => {
|
|
19
|
+
it("redacts plain email", () => {
|
|
20
|
+
const input = "admin@example.com";
|
|
21
|
+
const output = _cleanStringPII(input, "log");
|
|
22
|
+
|
|
23
|
+
expect(output).toBe("[REDACTED EMAIL]");
|
|
24
|
+
expect(mockMetricAdd).toHaveBeenCalledWith(
|
|
25
|
+
1,
|
|
26
|
+
expect.objectContaining({
|
|
27
|
+
pii_email_domain: "example.com",
|
|
28
|
+
pii_type: "email",
|
|
29
|
+
redaction_source: "log",
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("redacts email in URL-encoded string", () => {
|
|
35
|
+
const input = "user%40gmail.com";
|
|
36
|
+
const output = _cleanStringPII(input, "log");
|
|
37
|
+
|
|
38
|
+
expect(output).toBe("[REDACTED EMAIL]");
|
|
39
|
+
expect(mockMetricAdd).toHaveBeenCalledWith(
|
|
40
|
+
1,
|
|
41
|
+
expect.objectContaining({
|
|
42
|
+
pii_format: "url",
|
|
43
|
+
pii_email_domain: "gmail.com",
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("handles strings without email unchanged", () => {
|
|
49
|
+
const input = "hello world";
|
|
50
|
+
const output = _cleanStringPII(input, "log");
|
|
51
|
+
|
|
52
|
+
expect(output).toBe("hello world");
|
|
53
|
+
expect(mockMetricAdd).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("handles array of strings", () => {
|
|
57
|
+
const input = ["one@gmail.com", "two@example.com"];
|
|
58
|
+
const output = _cleanStringPII(input, "log");
|
|
59
|
+
|
|
60
|
+
expect(output).toEqual(["[REDACTED EMAIL]", "[REDACTED EMAIL]"]);
|
|
61
|
+
expect(mockMetricAdd).toHaveBeenCalledTimes(2);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("ignores non-string input", () => {
|
|
65
|
+
expect(_cleanStringPII(1234, "trace")).toBe(1234);
|
|
66
|
+
expect(_cleanStringPII(true, "trace")).toBe(true);
|
|
67
|
+
expect(_cleanStringPII(undefined, "trace")).toBeUndefined();
|
|
68
|
+
expect(mockMetricAdd).not.toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("_cleanLogBodyPII", () => {
|
|
73
|
+
it("cleans string email", () => {
|
|
74
|
+
const result = _cleanLogBodyPII("demo@abc.com");
|
|
75
|
+
expect(result).toBe("[REDACTED EMAIL]");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("cleans deeply nested object", () => {
|
|
79
|
+
const input = {
|
|
80
|
+
user: {
|
|
81
|
+
email: "test@gmail.com",
|
|
82
|
+
profile: {
|
|
83
|
+
contact: "foo@example.com",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
status: "active",
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const result = _cleanLogBodyPII(input);
|
|
90
|
+
|
|
91
|
+
expect(result).toEqual({
|
|
92
|
+
user: {
|
|
93
|
+
email: "[REDACTED EMAIL]",
|
|
94
|
+
profile: {
|
|
95
|
+
contact: "[REDACTED EMAIL]",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
status: "active",
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("cleans Uint8Array input", () => {
|
|
103
|
+
const str = "admin@gmail.com";
|
|
104
|
+
const buffer = new TextEncoder().encode(str);
|
|
105
|
+
const result = _cleanLogBodyPII(buffer);
|
|
106
|
+
const decoded = new TextDecoder().decode(result as Uint8Array);
|
|
107
|
+
|
|
108
|
+
expect(decoded).toBe("[REDACTED EMAIL]");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("skips malformed Uint8Array decode", () => {
|
|
112
|
+
const corrupted = new Uint8Array([0xff, 0xfe, 0xfd]);
|
|
113
|
+
const result = _cleanLogBodyPII(corrupted);
|
|
114
|
+
|
|
115
|
+
// Should return a Uint8Array, but unmodified/redaction should not happen
|
|
116
|
+
expect(result).toBeInstanceOf(Uint8Array);
|
|
117
|
+
expect(result).not.toEqual(expect.arrayContaining([91, 82, 69]));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("cleans arrays of values", () => {
|
|
121
|
+
const result = _cleanLogBodyPII([
|
|
122
|
+
"bob@abc.com",
|
|
123
|
+
123,
|
|
124
|
+
{ nested: "jane@example.com" },
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
expect(result).toEqual([
|
|
128
|
+
"[REDACTED EMAIL]",
|
|
129
|
+
123,
|
|
130
|
+
{ nested: "[REDACTED EMAIL]" },
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("passes null and boolean through", () => {
|
|
135
|
+
expect(_cleanLogBodyPII(null)).toBeNull();
|
|
136
|
+
expect(_cleanLogBodyPII(undefined)).toBeUndefined();
|
|
137
|
+
expect(_cleanLogBodyPII(true)).toBe(true);
|
|
138
|
+
expect(_cleanLogBodyPII(false)).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|