@ogcio/o11y-sdk-node 0.4.0 → 0.4.2

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/lib/config-manager.d.ts +1 -1
  3. package/dist/lib/config-manager.js +1 -4
  4. package/dist/lib/exporter/pii-exporter-decorator.d.ts +1 -1
  5. package/dist/lib/exporter/pii-exporter-decorator.js +10 -10
  6. package/dist/lib/internals/redaction/pii-detection.d.ts +11 -9
  7. package/dist/lib/internals/redaction/pii-detection.js +17 -24
  8. package/dist/lib/internals/redaction/redactors/ip.js +2 -2
  9. package/dist/lib/traces.js +1 -1
  10. package/dist/package.json +1 -1
  11. package/dist/vitest.config.js +1 -1
  12. package/lib/config-manager.ts +2 -6
  13. package/lib/exporter/pii-exporter-decorator.ts +15 -12
  14. package/lib/internals/redaction/pii-detection.ts +27 -40
  15. package/lib/internals/redaction/redactors/ip.ts +2 -2
  16. package/lib/traces.ts +2 -2
  17. package/package.json +1 -1
  18. package/test/config-manager.test.ts +2 -2
  19. package/test/integration/README.md +59 -11
  20. package/test/integration/docker-utils.sh +214 -0
  21. package/test/integration/main.sh +52 -0
  22. package/test/integration/teardown.sh +7 -0
  23. package/test/integration/{http-tracing.integration.test.ts → test_fastify-o11y-pii-enabled/http-tracing.integration.test.ts} +1 -1
  24. package/test/integration/{pii.integration.test.ts → test_fastify-o11y-pii-enabled/pii.integration.test.ts} +1 -1
  25. package/test/integration/test_fastify-o11y-pii-enabled/run.sh +42 -0
  26. package/test/integration/test_without-o11y/run.sh +30 -0
  27. package/test/integration/test_without-o11y/verify-status.integration.test.ts +32 -0
  28. package/test/internals/pii-detection.test.ts +142 -20
  29. package/test/internals/redactors/ip.test.ts +4 -0
  30. package/test/traces/active-span.test.ts +2 -4
  31. package/test/traces/with-span.test.ts +16 -0
  32. package/vitest.config.ts +1 -1
  33. package/test/integration/run.sh +0 -88
@@ -0,0 +1,214 @@
1
+ #!/bin/bash
2
+
3
+ : '
4
+ Build a new docker image of a given Dockerfile, if already exists skip build.
5
+
6
+ @param container_name docker container name
7
+
8
+ @return int error code
9
+ 0. success
10
+ 1. validation error
11
+ '
12
+ build_image() {
13
+ if [ "$#" -lt 3 ] || [ "$#" -gt 4 ]; then
14
+ echo "Error: build_image expected 3 or 4 parameters, got $#." >&2
15
+ echo "Usage: build_image <image_name> <dockerfile_path> <context_path> [max_age_minutes]" >&2
16
+ return 1
17
+ fi
18
+
19
+
20
+ local image_name="$1"
21
+ local dockerfile_path="$2"
22
+ local context_path="$3"
23
+ local max_age_minutes="${4:-30}" # default threshold = 30 minutes
24
+
25
+ if docker image inspect "$image_name" > /dev/null 2>&1; then
26
+ echo "Image $image_name already exists. Checking age..."
27
+
28
+ # Extract the Created timestamp from docker inspect (ISO 8601 format)
29
+ local created_at
30
+ created_at=$(docker image inspect --format '{{.Created}}' "$image_name")
31
+ if [ -z "$created_at" ]; then
32
+ echo "Warning: Could not determine creation date. Proceeding without rebuild check." >&2
33
+ return 0
34
+ fi
35
+
36
+ # Convert created_at to epoch seconds
37
+ local created_epoch
38
+ created_epoch=$(date -d "$created_at" +%s 2>/dev/null)
39
+
40
+ # Current time in epoch seconds
41
+ local now_epoch
42
+ now_epoch=$(date +%s)
43
+
44
+ # Calculate age in minutes
45
+ local age_minutes=$(( (now_epoch - created_epoch) / 60 ))
46
+
47
+ echo "Image $image_name is $age_minutes minutes old (threshold: $max_age_minutes minutes)."
48
+
49
+ if [ "$age_minutes" -ge "$max_age_minutes" ]; then
50
+ echo "Image is too old. Rebuilding..."
51
+ docker build -t $image_name -f $dockerfile_path $context_path
52
+ return $?
53
+ else
54
+ echo "Image is recent. Skipping rebuild."
55
+ return 0
56
+ fi
57
+ else
58
+ echo "Image $image_name not found. Building..."
59
+ docker build -t "$image_name" -f "$dockerfile_path" "$context_path"
60
+ return $?
61
+ fi
62
+ }
63
+
64
+ : '
65
+ Stop and remove a docker container for the given input container name.
66
+
67
+ @param container_name docker container name
68
+
69
+ @return int error code
70
+ 0. success
71
+ 1. validation error
72
+ '
73
+ container_stop_and_rm() {
74
+ local container_name="$1"
75
+
76
+ if [[ -z "$container_name" ]]; then
77
+ echo "Error: container_name must have a value."
78
+ return 1
79
+ fi
80
+
81
+ docker container stop $container_name
82
+ docker container rm -f $container_name
83
+
84
+ return $?
85
+ }
86
+
87
+ : '
88
+ Run a new alloy container and check if the status is running.
89
+
90
+ @param root_path volume root path for alloy file config
91
+ @param network_name docker network name
92
+ @param container_name docker container name
93
+
94
+ @optional max_retries
95
+ @default 10
96
+
97
+ @return int error code
98
+ 0. success
99
+ 1. validation error
100
+ 2. execution failed
101
+ '
102
+ run_and_check_alloy() {
103
+
104
+ if [ "$#" -lt 3 ]; then
105
+ echo "Error: run_and_check_alloy expected 3 parameters, got $#." >&2
106
+ return 1
107
+ fi
108
+
109
+ local root_path="$1"
110
+ local network_name="$2"
111
+ local container_name="$3"
112
+ local max_retries="${4:-10}"
113
+ local counter_retries=0
114
+
115
+ if [[ -z "$root_path" || -z "$network_name" || -z "$container_name" ]]; then
116
+ echo "Error: root_path, network_name, and container_name must all have values."
117
+ return 1
118
+ fi
119
+
120
+ if ! [[ "$max_retries" =~ ^[0-9]+$ ]]; then
121
+ echo "Error: max_retries parameter must be a number." >&2
122
+ return 1
123
+ fi
124
+
125
+ docker run -d \
126
+ -v "$root_path/alloy/integration-test.alloy:/etc/alloy/config.alloy:ro" \
127
+ --network $network_name \
128
+ --name $container_name \
129
+ -p 4317:4317 \
130
+ -p 4318:4318 \
131
+ grafana/alloy:v1.9.2 \
132
+ run --server.http.listen-addr=0.0.0.0:12345 --stability.level=experimental /etc/alloy/config.alloy
133
+
134
+ echo "$container_name container status"
135
+ until [ "$(docker inspect -f {{.State.Running}} $container_name)" = true ]; do
136
+ sleep 2
137
+ docker inspect -f {{.State.Running}} $container_name
138
+ counter_retries=$((counter_retries + 1))
139
+ if [ $counter_retries -ge $max_retries ]; then
140
+ echo "Exceeded maximum retries. Exiting."
141
+ return 2
142
+ fi
143
+ done
144
+
145
+ return 0
146
+ }
147
+
148
+ : '
149
+ Run a new fastify microservice container and check if the status is running.
150
+
151
+ @param network_name docker network name
152
+ @param collector_url otel collector grpc endpoint
153
+ @param container_name docker container name
154
+ @param build_id current pipeline build id
155
+
156
+ @optional max_retries
157
+ @default 10
158
+
159
+ @return int error code
160
+ 0. success
161
+ 1. validation error
162
+ 2. execution failed
163
+ '
164
+ run_and_check_fastify() {
165
+
166
+ if [ "$#" -lt 4 ]; then
167
+ echo "Error: run_and_check_alloy expected 3 parameters, got $#." >&2
168
+ return 1
169
+ fi
170
+
171
+ local network_name="$1"
172
+ local collector_url="$2"
173
+ local container_name="$3"
174
+ local build_id="$4"
175
+ local max_retries="${5:-10}"
176
+ local counter_retries=0
177
+
178
+ if [[ -z "$build_id" || -z "$network_name" || -z "$container_name" ]]; then
179
+ echo "Error: build_id, network_name, and container_name must all have values."
180
+ return 1
181
+ fi
182
+
183
+ if ! [[ "$max_retries" =~ ^[0-9]+$ ]]; then
184
+ echo "Error: max_retries parameter must be a number." >&2
185
+ return 1
186
+ fi
187
+
188
+ docker run --detach \
189
+ --network $network_name \
190
+ --name $container_name \
191
+ -e DB_DISABLED="true" \
192
+ -e SERVER_HOST="0.0.0.0" \
193
+ -e OTEL_COLLECTOR_URL=$collector_url \
194
+ --health-cmd="curl -f http://${container_name}:9091/api/health > /dev/null || exit 1" \
195
+ --health-start-period=1s \
196
+ --health-retries=10 \
197
+ --health-interval=1s \
198
+ -p 9091:9091 \
199
+ $container_name:$build_id
200
+
201
+ counter_retries=0
202
+ echo "$container_name container status"
203
+ until [ "$(docker inspect -f {{.State.Health.Status}} $container_name)" = "healthy" ]; do
204
+ sleep 1
205
+ docker inspect -f {{.State.Health.Status}} $container_name
206
+ counter_retries=$((counter_retries + 1))
207
+ if [ $counter_retries -ge $max_retries ]; then
208
+ echo "Exceeded maximum retries. Exiting."
209
+ return 2
210
+ fi
211
+ done
212
+
213
+ return 0
214
+ }
@@ -0,0 +1,52 @@
1
+ #!/bin/bash
2
+
3
+ set -e
4
+ set -o pipefail
5
+
6
+ if [ $# -lt 2 ]; then
7
+ echo "Usage: $0 <build-id> <root-path>"
8
+ exit 1
9
+ fi
10
+
11
+ BUILD_ID=$1
12
+ ROOT_PATH=$2
13
+
14
+ NETWORK_NAME="${BUILD_ID}_testnetwork"
15
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
+
17
+ docker network create $NETWORK_NAME
18
+
19
+ TEST_SCRIPTS=( $(find . -type f -path "./packages/sdk-node/test/integration/test*/run.sh" | sort) )
20
+
21
+ FAILURES=0
22
+
23
+ for test_script in "${TEST_SCRIPTS[@]}"; do
24
+ TEST_DIR=$(dirname "$test_script")
25
+ TEST_NAME=$(basename "$TEST_DIR")
26
+
27
+ echo "Running test: $TEST_NAME ($test_script)"
28
+
29
+ # Run the test
30
+ bash "$test_script" "$BUILD_ID" "$ROOT_PATH" "$NETWORK_NAME"
31
+ EXIT_CODE=$?
32
+
33
+ if [ $EXIT_CODE -eq 0 ]; then
34
+ echo "$test_script completed"
35
+ else
36
+ echo "❌ $test_script failed with exit code $EXIT_CODE"
37
+ FAILURES=$((FAILURES + 1))
38
+ fi
39
+ echo "------------------------------"
40
+ done
41
+
42
+ # Remove network and images from docker host
43
+ bash "$SCRIPT_DIR/teardown.sh" "$BUILD_ID" "$NETWORK_NAME"
44
+
45
+ # Final summary
46
+ if [ $FAILURES -eq 0 ]; then
47
+ echo "All tests ready!"
48
+ exit 0
49
+ else
50
+ echo "$FAILURES test(s) failed."
51
+ exit 1
52
+ fi
@@ -0,0 +1,7 @@
1
+ #!/bin/bash
2
+
3
+ BUILD_ID=$1
4
+ NETWORK_NAME=$2
5
+
6
+ docker network rm $NETWORK_NAME
7
+ docker rmi -f $(docker images --filter "reference=*:$BUILD_ID" -q)
@@ -1,5 +1,5 @@
1
1
  import { describe, test, assert } from "vitest";
2
- import { parseLog } from "../utils/alloy-log-parser";
2
+ import { parseLog } from "../../utils/alloy-log-parser";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { describe, test, assert } from "vitest";
2
- import { parseLog } from "../utils/alloy-log-parser";
2
+ import { parseLog } from "../../utils/alloy-log-parser";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
 
@@ -0,0 +1,42 @@
1
+ #!/bin/bash
2
+ # @docs: packages/sdk-node/test/integration/README.md#test_fastify-o11y-pii-enabled
3
+ BUILD_ID=$1
4
+ ROOT_PATH=$2
5
+ NETWORK_NAME=$3
6
+
7
+ ALLOY_CONTAINER_NAME="integrationalloy"
8
+ NODE_CONTAINER_NAME="${BUILD_ID}_fastify_app"
9
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ LOG_FILE="$SCRIPT_DIR/logs.txt"
11
+ ERROR_CODE=0
12
+
13
+ source "$SCRIPT_DIR/../docker-utils.sh"
14
+
15
+ build_image "${NODE_CONTAINER_NAME}:${BUILD_ID}" "$ROOT_PATH/examples/fastify/Dockerfile" "$ROOT_PATH/"
16
+
17
+ run_and_check_alloy $ROOT_PATH $NETWORK_NAME $ALLOY_CONTAINER_NAME
18
+ ERROR_CODE=$?
19
+
20
+ if [[ $ERROR_CODE -eq 0 ]]; then
21
+ run_and_check_fastify $NETWORK_NAME "http://integrationalloy:4317" $NODE_CONTAINER_NAME $BUILD_ID
22
+ ERROR_CODE=$?
23
+ fi
24
+
25
+ if [[ $ERROR_CODE -eq 0 ]]; then
26
+ sleep 2
27
+ curl -X GET -f http://localhost:9091/api/dummy -H 'Content-Type: application/json'
28
+ sleep 2
29
+ curl -X GET -f http://localhost:9091/api/dummy -H 'Content-Type: application/json'
30
+ fi
31
+
32
+ # sleep N seconds to await instrumentation flow send and receiving signals
33
+ sleep 1
34
+
35
+ # Copy logs from container to file
36
+ docker container logs $ALLOY_CONTAINER_NAME >&$LOG_FILE
37
+ echo "log file at $LOG_FILE"
38
+
39
+ container_stop_and_rm $ALLOY_CONTAINER_NAME
40
+ container_stop_and_rm $NODE_CONTAINER_NAME
41
+
42
+ exit $ERROR_CODE
@@ -0,0 +1,30 @@
1
+ #!/bin/bash
2
+ # @docs: packages/sdk-node/test/integration/README.md#test_without-o11y
3
+ BUILD_ID=$1
4
+ ROOT_PATH=$2
5
+ NETWORK_NAME=$3
6
+
7
+ NODE_CONTAINER_NAME="${BUILD_ID}_fastify_app"
8
+ ERROR_CODE=0
9
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ LOG_FILE="$SCRIPT_DIR/logs.txt"
11
+
12
+ source "$SCRIPT_DIR/../docker-utils.sh"
13
+
14
+ build_image "${NODE_CONTAINER_NAME}:${BUILD_ID}" "$ROOT_PATH/examples/fastify/Dockerfile" "$ROOT_PATH/"
15
+
16
+ run_and_check_fastify $NETWORK_NAME "" $NODE_CONTAINER_NAME $BUILD_ID
17
+ ERROR_CODE=$?
18
+
19
+ if [[ $ERROR_CODE -eq 0 ]]; then
20
+ curl -X GET -f http://localhost:9091/api/dummy -H 'Content-Type: application/json'
21
+ sleep 1
22
+ curl -X GET -f http://localhost:9091/api/dummy -H 'Content-Type: application/json'
23
+ fi
24
+
25
+ docker container logs $NODE_CONTAINER_NAME >&$LOG_FILE
26
+ echo "log file at $LOG_FILE"
27
+
28
+ container_stop_and_rm $NODE_CONTAINER_NAME
29
+
30
+ exit $ERROR_CODE
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ describe("Log validation for /api/dummy with instrumentation disabled", async () => {
6
+ const log = await readFile(join(__dirname, "logs.txt"), "utf-8");
7
+
8
+ it("should contain message that collector URL is not set", () => {
9
+ expect(log).toContain(
10
+ "collectorUrl not set. Skipping NodeJS OpenTelemetry instrumentation.",
11
+ );
12
+ });
13
+
14
+ it("should have all /api/dummy responses with status code 200", () => {
15
+ // Extract all request IDs for /api/dummy requests
16
+ const dummyRequestIds = [
17
+ ...log.matchAll(/"reqId":"(.*?)","req":\{[^}]*"url":"\/api\/dummy"/g),
18
+ ].map((match) => match[1]);
19
+
20
+ expect(dummyRequestIds.length).toBeGreaterThan(0);
21
+
22
+ // For each /api/dummy request ID, verify that its response status is 200
23
+ const non200Responses = dummyRequestIds.filter((reqId) => {
24
+ const responseMatch = log.match(
25
+ new RegExp(`"reqId":"${reqId}".*"statusCode":(\\d+)`),
26
+ );
27
+ return responseMatch && Number(responseMatch[1]) !== 200;
28
+ });
29
+
30
+ expect(non200Responses).toEqual([]); // No non-200 responses allowed
31
+ });
32
+ });
@@ -1,10 +1,12 @@
1
1
  import { describe, expect, it, vi, beforeEach } from "vitest";
2
2
  import {
3
3
  _cleanStringPII,
4
- _cleanLogBodyPII,
4
+ _containsEncodedComponents,
5
+ _recursiveObjectClean,
5
6
  } from "../../lib/internals/redaction/pii-detection.js";
6
7
  import * as sharedMetrics from "../../lib/internals/shared-metrics.js";
7
8
  import { emailRedactor } from "../../lib/internals/redaction/redactors/email";
9
+ import { ipRedactor } from "../../lib/internals/redaction/redactors/ip";
8
10
 
9
11
  describe("PII Detection Utils", () => {
10
12
  const mockMetricAdd = vi.fn();
@@ -32,7 +34,7 @@ describe("PII Detection Utils", () => {
32
34
  );
33
35
  });
34
36
 
35
- it("redacts PII in URL-encoded string", () => {
37
+ it("redacts email in URL-encoded string", () => {
36
38
  const input = "user%40gmail.com";
37
39
  const output = _cleanStringPII(input, "log", [emailRedactor]);
38
40
 
@@ -46,6 +48,21 @@ describe("PII Detection Utils", () => {
46
48
  );
47
49
  });
48
50
 
51
+ it("redacts ip in URL-encoded string", () => {
52
+ const input = "%20127.0.0.1";
53
+ const output = _cleanStringPII(input, "log", [ipRedactor]);
54
+
55
+ expect(output).toBe(" [REDACTED IPV4]");
56
+ expect(mockMetricAdd).toHaveBeenCalledWith(
57
+ 1,
58
+ expect.objectContaining({
59
+ pii_format: "url",
60
+ pii_type: "IPv4",
61
+ redaction_source: "log",
62
+ }),
63
+ );
64
+ });
65
+
49
66
  it("handles strings without PII unchanged", () => {
50
67
  const input = "hello world";
51
68
  const output = _cleanStringPII(input, "log", [emailRedactor]);
@@ -54,16 +71,10 @@ describe("PII Detection Utils", () => {
54
71
  expect(mockMetricAdd).not.toHaveBeenCalled();
55
72
  });
56
73
 
57
- it("handles array of strings", () => {
58
- const input = ["one@gmail.com", "two@example.com"];
59
- const output = _cleanStringPII(input, "log", [emailRedactor]);
60
-
61
- expect(output).toEqual(["[REDACTED EMAIL]", "[REDACTED EMAIL]"]);
62
- expect(mockMetricAdd).toHaveBeenCalledTimes(2);
63
- });
64
-
65
74
  it("ignores non-string input", () => {
75
+ // @ts-expect-error
66
76
  expect(_cleanStringPII(1234, "trace", [emailRedactor])).toBe(1234);
77
+ // @ts-expect-error
67
78
  expect(_cleanStringPII(true, "trace", [emailRedactor])).toBe(true);
68
79
  expect(
69
80
  _cleanStringPII(undefined, "trace", [emailRedactor]),
@@ -72,12 +83,22 @@ describe("PII Detection Utils", () => {
72
83
  });
73
84
  });
74
85
 
75
- describe("_cleanLogBodyPII", () => {
86
+ describe("_recursiveObjectClean", () => {
76
87
  it("cleans string PII", () => {
77
- const result = _cleanLogBodyPII("demo@abc.com", [emailRedactor]);
88
+ const result = _recursiveObjectClean("demo@abc.com", "log", [
89
+ emailRedactor,
90
+ ]);
78
91
  expect(result).toBe("[REDACTED EMAIL]");
79
92
  });
80
93
 
94
+ it("cleans array of strings", () => {
95
+ const input = ["one@gmail.com", "two@example.com"];
96
+ const output = _recursiveObjectClean(input, "log", [emailRedactor]);
97
+
98
+ expect(output).toEqual(["[REDACTED EMAIL]", "[REDACTED EMAIL]"]);
99
+ expect(mockMetricAdd).toHaveBeenCalledTimes(2);
100
+ });
101
+
81
102
  it("cleans deeply nested object", () => {
82
103
  const input = {
83
104
  user: {
@@ -89,7 +110,7 @@ describe("PII Detection Utils", () => {
89
110
  status: "active",
90
111
  };
91
112
 
92
- const result = _cleanLogBodyPII(input, [emailRedactor]);
113
+ const result = _recursiveObjectClean(input, "log", [emailRedactor]);
93
114
 
94
115
  expect(result).toEqual({
95
116
  user: {
@@ -105,7 +126,7 @@ describe("PII Detection Utils", () => {
105
126
  it("cleans Uint8Array input", () => {
106
127
  const str = "admin@gmail.com";
107
128
  const buffer = new TextEncoder().encode(str);
108
- const result = _cleanLogBodyPII(buffer, [emailRedactor]);
129
+ const result = _recursiveObjectClean(buffer, "log", [emailRedactor]);
109
130
  const decoded = new TextDecoder().decode(result as Uint8Array);
110
131
 
111
132
  expect(decoded).toBe("[REDACTED EMAIL]");
@@ -113,7 +134,7 @@ describe("PII Detection Utils", () => {
113
134
 
114
135
  it("skips malformed Uint8Array decode", () => {
115
136
  const corrupted = new Uint8Array([0xff, 0xfe, 0xfd]);
116
- const result = _cleanLogBodyPII(corrupted, [emailRedactor]);
137
+ const result = _recursiveObjectClean(corrupted, "log", [emailRedactor]);
117
138
 
118
139
  // Should return a Uint8Array, but unmodified/redaction should not happen
119
140
  expect(result).toBeInstanceOf(Uint8Array);
@@ -121,8 +142,9 @@ describe("PII Detection Utils", () => {
121
142
  });
122
143
 
123
144
  it("cleans arrays of values", () => {
124
- const result = _cleanLogBodyPII(
145
+ const result = _recursiveObjectClean(
125
146
  ["bob@abc.com", 123, { nested: "jane@example.com" }],
147
+ "log",
126
148
  [emailRedactor],
127
149
  );
128
150
 
@@ -134,10 +156,110 @@ describe("PII Detection Utils", () => {
134
156
  });
135
157
 
136
158
  it("passes null and boolean through", () => {
137
- expect(_cleanLogBodyPII(null, [emailRedactor])).toBeNull();
138
- expect(_cleanLogBodyPII(undefined, [emailRedactor])).toBeUndefined();
139
- expect(_cleanLogBodyPII(true, [emailRedactor])).toBe(true);
140
- expect(_cleanLogBodyPII(false, [emailRedactor])).toBe(false);
159
+ expect(_recursiveObjectClean(null, "log", [emailRedactor])).toBeNull();
160
+ expect(
161
+ _recursiveObjectClean(undefined, "log", [emailRedactor]),
162
+ ).toBeUndefined();
163
+ expect(_recursiveObjectClean(true, "log", [emailRedactor])).toBe(true);
164
+ expect(_recursiveObjectClean(false, "log", [emailRedactor])).toBe(false);
165
+ });
166
+ });
167
+
168
+ describe("_containsEncodedComponents", () => {
169
+ describe("should return true for properly URL encoded strings", () => {
170
+ it.each([
171
+ ["hello%20world", "Space encoded as %20"],
172
+ ["test%2Bvalue", "Plus sign encoded as %2B"],
173
+ ["path%2Fto%2Ffile", "Forward slashes encoded"],
174
+ ["user%40domain.com", "@ symbol encoded"],
175
+ ["100%25%20off", "Percent and space encoded"],
176
+ ["a%3Db%26c%3Dd", "Query parameters (a=b&c=d)"],
177
+ ["caf%C3%A9", "UTF-8 encoded (café)"],
178
+ ["price%3A%20%2410", "Colon, space, dollar ($10)"],
179
+ ["%22quoted%22", "Double quotes encoded"],
180
+ ["https%3A%2F%2Fexample.com", "Full URL encoded"],
181
+ ["file%20name.txt", "filename encoded"],
182
+ ["search%3Fq%3Dhello%20world", "Query string encoded"],
183
+ ["%3C%3E%26%22%27", "HTML special chars encoded (<>&\"')"],
184
+ ["%E2%9C%93", "UTF-8 checkmark (✓) encoded"],
185
+ ["test%2b", "Lowercase hex"],
186
+ ["%E4%B8%AD%E6%96%87", "Chinese characters encoded"],
187
+ ])('should detect "%s" as URL encoded (%s)', (input, description) => {
188
+ expect(_containsEncodedComponents(input)).toBe(true);
189
+ });
190
+ });
191
+
192
+ describe("should return false for non-URL encoded strings", () => {
193
+ it.each([
194
+ ["test", "Simple ASCII string"],
195
+ ["hello world", "Unencoded space"],
196
+ ["user@domain.com", "Unencoded email"],
197
+ ["simple123", "Alphanumeric only"],
198
+ ["", "Empty string"],
199
+ ["25%%", "Literal percent signs"],
200
+ ["100% off", "Percent without hex digits"],
201
+ ["test%2", "Incomplete percent encoding"],
202
+ ["hello%ZZ", "Invalid hex digits"],
203
+ ["test%2G", "Invalid hex digit G"],
204
+ ["bad%encoding%here", "Percent without hex pairs"],
205
+ ["hello%20world and more", "Partially encoded string"],
206
+ ["hello%20world%21%20how%20are%20you", "Overly encoded string"],
207
+ ["café", "Unicode characters (unencoded)"],
208
+ ["hello+world", "Plus sign (form encoding style)"],
209
+ ["test%", "Trailing percent"],
210
+ ["hello%20world%", "Encoded content with trailing percent"],
211
+ ["%", "Single percent"],
212
+ ["%%", "Double percent"],
213
+ ["normal text with % symbols", "Text with percent but no encoding"],
214
+ ["price: $100%", "Currency with percent"],
215
+ ["file.txt", "Simple filename"],
216
+ ["path/to/file", "Unencoded path"],
217
+ ["query?param=value", "Unencoded query string"],
218
+ ["hello%world", "Percent without following hex"],
219
+ ["test%1", "Single hex digit after percent"],
220
+ ["hello%zz", "Non-hex characters after percent"],
221
+ ])('should not detect "%s" as URL encoded (%s)', (input, description) => {
222
+ expect(_containsEncodedComponents(input)).toBe(false);
223
+ });
224
+ });
225
+
226
+ describe("Error handling", () => {
227
+ it.each([
228
+ ["%C0%80", "Overlong UTF-8 encoding (security concern)"],
229
+ ["%ED%A0%80", "UTF-8 surrogate (invalid)"],
230
+ ["%FF%FE", "Invalid UTF-8 sequence"],
231
+ ["test%20%ZZ", "Mix of valid and invalid encoding"],
232
+ ])('should handle "%s" (%s)', (input, description) => {
233
+ // These should not throw errors
234
+ expect(() => _containsEncodedComponents(input)).not.toThrow();
235
+
236
+ // Most of these should return false due to invalid sequences
237
+ // or security-related encoding issues
238
+ const result = _containsEncodedComponents(input);
239
+ expect(typeof result).toBe("boolean");
240
+ });
241
+ });
242
+
243
+ describe("real-world url examples", () => {
244
+ it.each([
245
+ [
246
+ "https%3A%2F%2Fgoogle.com%2Fsearch%3Fq%3Djavascript",
247
+ "Encoded Google search URL",
248
+ ],
249
+ ["The%20quick%20brown%20fox", "Sentence with spaces encoded"],
250
+ [
251
+ "/api/user?body=here%20are%20all%20my%20secrets",
252
+ "contextually encoded API path",
253
+ ],
254
+ ["redirect_uri=https%3A%2F%2Fapp.com%2Fcallback", "OAuth redirect URI"],
255
+ ["data%3Atext%2Fplain%3Bbase64%2CSGVsbG8%3D", "Data URL encoded"],
256
+ ])('real-world case: "%s" (%s)', (input, description) => {
257
+ const result = _containsEncodedComponents(input);
258
+
259
+ // Verify the function doesn't crash
260
+ expect(typeof result).toBe("boolean");
261
+ expect(result).toBe(true);
262
+ });
141
263
  });
142
264
  });
143
265
  });
@@ -67,9 +67,13 @@ describe("IP Redaction utils", () => {
67
67
  ${"256.1.1.1"} | ${"256.1.1.1"}
68
68
  ${"0.0.0.0!"} | ${"[REDACTED IPV4]!"}
69
69
  ${"text0.0.0.0"} | ${"text[REDACTED IPV4]"}
70
+ ${"%A00.0.0.0"} | ${"[REDACTED IPV4]"}
71
+ ${"0.0.0.0%20"} | ${"[REDACTED IPV4]"}
70
72
  ${"0.0.text0.0"} | ${"0.0.text0.0"}
71
73
  ${"2001:0db8::1"} | ${"[REDACTED IPV6]"}
72
74
  ${"::1"} | ${"[REDACTED IPV6]"}
75
+ ${"%20::1"} | ${"[REDACTED IPV6]"}
76
+ ${"::1%A0"} | ${"[REDACTED IPV6]"}
73
77
  ${"text::1"} | ${"text[REDACTED IPV6]"}
74
78
  ${"::1text"} | ${"[REDACTED IPV6]text"}
75
79
  ${"sentence ending with f::1"} | ${"sentence ending with [REDACTED IPV6]"}
@@ -1,8 +1,6 @@
1
- import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2
- import * as piiDetection from "../../lib/internals/redaction/pii-detection.js";
3
- import { getActiveSpan } from "../../lib/traces.js";
4
- import { MockSpan } from "../utils/mock-signals.js";
1
+ import { describe, expect, it, vi } from "vitest";
5
2
  import { setNodeSdkConfig } from "../../lib/config-manager.js";
3
+ import { getActiveSpan } from "../../lib/traces.js";
6
4
 
7
5
  describe("getActiveSpan", () => {
8
6
  it("returns undefined if no active span", async () => {