diff --git a/.changeset/unflatten-attributes-conflict.md b/.changeset/unflatten-attributes-conflict.md new file mode 100644 index 00000000000..9df627f2630 --- /dev/null +++ b/.changeset/unflatten-attributes-conflict.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Fix `TypeError` in `unflattenAttributes` when the input attribute map contains conflicting dotted key paths (e.g. both `a.b` set to a scalar and `a.b.c` set to a value). The path-walk loop now applies last-write-wins when a prior key wrote a primitive, null, or array at an intermediate slot, matching the existing precedent in `AttributeFlattener.addAttribute`. Callers no longer crash when handed malformed external attribute inputs. diff --git a/packages/core/src/v3/utils/flattenAttributes.ts b/packages/core/src/v3/utils/flattenAttributes.ts index 83d1a14f2cd..1a4486a4e5d 100644 --- a/packages/core/src/v3/utils/flattenAttributes.ts +++ b/packages/core/src/v3/utils/flattenAttributes.ts @@ -312,10 +312,16 @@ export function unflattenAttributes( } if (typeof nextPart === "number") { - // Ensure we create an array for numeric indices - current[part] = Array.isArray(current[part]) ? current[part] : []; - } else if (current[part] === undefined) { - // Create an object for non-numeric paths + if (!Array.isArray(current[part])) { + current[part] = []; + } + } else if ( + current[part] === null || + typeof current[part] !== "object" || + Array.isArray(current[part]) + ) { + // Last-write-wins when a prior key wrote a primitive, null, or array + // at this slot — keeps unflatten total for conflicting OTLP inputs. current[part] = {}; } diff --git a/packages/core/test/flattenAttributes.test.ts b/packages/core/test/flattenAttributes.test.ts index 28f137deaf9..3fd11fa5d04 100644 --- a/packages/core/test/flattenAttributes.test.ts +++ b/packages/core/test/flattenAttributes.test.ts @@ -667,4 +667,54 @@ describe("unflattenAttributes", () => { } expect(current).toBeUndefined(); }); + + // Defends against external OTLP producers that emit both a leaf value and a + // nested path through the same prefix in one attribute map (e.g. AI SDK + // telemetry on certain models). The flattener can't produce these, but the + // unflattener used to crash with TypeError when it tried to descend into a + // primitive sibling. + it("does not throw when a scalar precedes a deeper path at the same prefix", () => { + expect(() => + unflattenAttributes({ "a.b": "scalar", "a.b.c": "value" }) + ).not.toThrow(); + expect(unflattenAttributes({ "a.b": "scalar", "a.b.c": "value" })).toEqual({ + a: { b: { c: "value" } }, + }); + }); + + it("does not throw when a deeper path precedes a scalar at the same prefix", () => { + expect(() => + unflattenAttributes({ "a.b.c": "value", "a.b": "scalar" }) + ).not.toThrow(); + expect(unflattenAttributes({ "a.b.c": "value", "a.b": "scalar" })).toEqual({ + a: { b: "scalar" }, + }); + }); + + it("treats an intermediate null sentinel as overwritable when a deeper path follows", () => { + expect(() => + unflattenAttributes({ "a.b": "$@null((", "a.b.c": "value" }) + ).not.toThrow(); + expect(unflattenAttributes({ "a.b": "$@null((", "a.b.c": "value" })).toEqual({ + a: { b: { c: "value" } }, + }); + }); + + it("does not throw when a scalar prefix conflicts with a numeric-index path", () => { + expect(() => + unflattenAttributes({ "a.b": "scalar", "a.b.[0]": "indexed" }) + ).not.toThrow(); + expect(unflattenAttributes({ "a.b": "scalar", "a.b.[0]": "indexed" })).toEqual({ + a: { b: ["indexed"] }, + }); + }); + + it("converts an existing object slot to an array when a numeric-index path follows", () => { + expect(() => + unflattenAttributes({ "a.b.c": "value", "a.b.[0]": "indexed" }) + ).not.toThrow(); + expect(unflattenAttributes({ "a.b.c": "value", "a.b.[0]": "indexed" })).toEqual({ + a: { b: ["indexed"] }, + }); + }); });