Skip to content

Commit 64e8cc2

Browse files
author
selenaalpha77-sketch
committed
fix(core): escape dots in object keys during attribute flattening (#1510)
Keys containing dots were incorrectly split on those dots during flattening, turning {"Key 0.002mm": 31.4} into {"Key 0": {"002mm": 31.4}} instead of keeping the key intact. Fix: escape dots (and backslashes) in key segments with backslash sequences during flattenAttributes, and unescape them in unflattenAttributes. The splitPath helper walks the string character-by-character so escaped delimiters are never treated as path separators. Fixes #1510
1 parent 19c1675 commit 64e8cc2

2 files changed

Lines changed: 102 additions & 4 deletions

File tree

packages/core/src/v3/utils/flattenAttributes.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,42 @@ import { Attributes } from "@opentelemetry/api";
33
export const NULL_SENTINEL = "$@null((";
44
export const CIRCULAR_REFERENCE_SENTINEL = "$@circular((";
55

6+
// Dots in key names are escaped so they are not mistaken for path delimiters.
7+
// We use the sequence "\." (backslash + dot). A literal backslash in a key
8+
// is itself escaped as "\\" so the encoding is unambiguous.
9+
function escapeKey(key: string): string {
10+
return key.replace(/\\/g, "\\\\").replace(/\./g, "\\.");
11+
}
12+
13+
function unescapeKey(key: string): string {
14+
return key.replace(/\\(.)/g, "$1");
15+
}
16+
17+
/**
18+
* Split a flattened path on unescaped "." separators.
19+
* Escaped dots ("\\.") are treated as literal dots within a key segment.
20+
*/
21+
function splitPath(path: string): string[] {
22+
// Walk the string character by character to correctly handle escape sequences.
23+
const parts: string[] = [];
24+
let current = "";
25+
for (let i = 0; i < path.length; i++) {
26+
const ch = path[i];
27+
if (ch === "\\" && i + 1 < path.length) {
28+
// Escape sequence – consume both characters verbatim.
29+
current += ch + path[i + 1];
30+
i++;
31+
} else if (ch === ".") {
32+
parts.push(current);
33+
current = "";
34+
} else {
35+
current += ch;
36+
}
37+
}
38+
parts.push(current);
39+
return parts;
40+
}
41+
642
const DEFAULT_MAX_DEPTH = 128;
743

844
export function flattenAttributes(
@@ -116,7 +152,7 @@ class AttributeFlattener {
116152
for (const [key, value] of obj) {
117153
if (!this.canAddMoreAttributes()) break;
118154
// Use the key directly if it's a string, otherwise convert it
119-
const keyStr = typeof key === "string" ? key : String(key);
155+
const keyStr = typeof key === "string" ? escapeKey(key) : String(key);
120156
this.#processValue(value, `${prefix || "map"}.${keyStr}`, depth);
121157
}
122158
return;
@@ -200,7 +236,7 @@ class AttributeFlattener {
200236
break;
201237
}
202238

203-
const newPrefix = `${prefix ? `${prefix}.` : ""}${Array.isArray(obj) ? `[${key}]` : key}`;
239+
const newPrefix = `${prefix ? `${prefix}.` : ""}${Array.isArray(obj) ? `[${key}]` : escapeKey(key)}`;
204240

205241
if (Array.isArray(value)) {
206242
for (let i = 0; i < value.length; i++) {
@@ -278,7 +314,7 @@ export function unflattenAttributes(
278314
continue;
279315
}
280316

281-
const parts = key.split(".").reduce(
317+
const parts = splitPath(key).reduce(
282318
(acc, part) => {
283319
if (part.startsWith("[") && part.endsWith("]")) {
284320
// Handle array indices more precisely
@@ -290,7 +326,7 @@ export function unflattenAttributes(
290326
acc.push(part.slice(1, -1));
291327
}
292328
} else {
293-
acc.push(part);
329+
acc.push(unescapeKey(part));
294330
}
295331
return acc;
296332
},

packages/core/test/flattenAttributes.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,52 @@ describe("flattenAttributes", () => {
547547
// Should complete without stack overflow
548548
expect(() => flattenAttributes({ arr: deepArray })).not.toThrow();
549549
});
550+
551+
it("handles object keys containing dots (issue #1510)", () => {
552+
// Keys with dots must not be treated as path separators during flattening.
553+
const obj = { "Key 0.002mm": 31.4 };
554+
const flattened = flattenAttributes(obj);
555+
556+
// The dot inside the key name must be escaped; the flat key should contain
557+
// the original key name, not split into sub-paths.
558+
expect(flattened).toHaveProperty("Key 0\\.002mm", 31.4);
559+
expect(flattened).not.toHaveProperty("Key 0");
560+
561+
// Round-tripping must restore the original structure.
562+
expect(unflattenAttributes(flattened)).toEqual(obj);
563+
});
564+
565+
it("handles nested objects where parent and child keys both contain dots", () => {
566+
const obj = { "a.b": { "c.d": "value" } };
567+
const flattened = flattenAttributes(obj);
568+
569+
expect(flattened).toHaveProperty("a\\.b.c\\.d", "value");
570+
expect(unflattenAttributes(flattened)).toEqual(obj);
571+
});
572+
573+
it("handles object keys containing backslashes", () => {
574+
const obj = { "path\\to\\file": "data" };
575+
const flattened = flattenAttributes(obj);
576+
577+
expect(flattened).toHaveProperty("path\\\\to\\\\file", "data");
578+
expect(unflattenAttributes(flattened)).toEqual(obj);
579+
});
580+
581+
it("handles object keys containing both dots and backslashes", () => {
582+
const obj = { "v1.2\\3": 42 };
583+
const flattened = flattenAttributes(obj);
584+
585+
expect(flattened).toHaveProperty("v1\\.2\\\\3", 42);
586+
expect(unflattenAttributes(flattened)).toEqual(obj);
587+
});
588+
589+
it("handles Map objects whose keys contain dots", () => {
590+
const myMap = new Map([["key.with.dots", "val"]]);
591+
const flattened = flattenAttributes({ myMap });
592+
593+
expect(flattened).toHaveProperty("myMap.key\\.with\\.dots", "val");
594+
expect(flattened).not.toHaveProperty("myMap.key");
595+
});
550596
});
551597

552598
describe("unflattenAttributes", () => {
@@ -667,4 +713,20 @@ describe("unflattenAttributes", () => {
667713
}
668714
expect(current).toBeUndefined();
669715
});
716+
717+
it("correctly reconstructs keys that contain escaped dots (issue #1510)", () => {
718+
// Escaped dot "\." in the flat key must become a literal "." in the
719+
// reconstructed object key, not a path separator.
720+
const flattened = { "Key 0\\.002mm": 31.4 };
721+
const result = unflattenAttributes(flattened);
722+
723+
expect(result).toEqual({ "Key 0.002mm": 31.4 });
724+
});
725+
726+
it("correctly reconstructs nested paths with escaped dots in key segments", () => {
727+
const flattened = { "a\\.b.c\\.d": "value" };
728+
const result = unflattenAttributes(flattened);
729+
730+
expect(result).toEqual({ "a.b": { "c.d": "value" } });
731+
});
670732
});

0 commit comments

Comments
 (0)