Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-dotted-keys-logging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---

Fix issue where logging objects with keys containing dots resulted in incorrect nested object structure in logs (#1510)
37 changes: 34 additions & 3 deletions packages/core/src/v3/utils/flattenAttributes.ts

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Map keys containing dots are not escaped, causing incorrect unflattening

The PR fixes dotted keys for regular objects by using escapeKey() at line 233, but Map keys at line 150 are not escaped.

Click to expand

How the bug is triggered

When a Map has a key containing a dot (e.g., "Key 0.002mm"), the current code builds the path without escaping:

// Line 150 - Map keys are NOT escaped
this.#processValue(value, `${prefix || "map"}.${keyStr}`, depth);

Compare to regular object keys at line 233:

// Line 233 - Object keys ARE escaped
const newPrefix = `${prefix ? `${prefix}.` : ""}${Array.isArray(obj) ? `[${key}]` : escapeKey(key)}`;

Actual vs Expected behavior

Actual: Map with key "Key 0.002mm" produces flattened path myMap.Key 0.002mm, which splitPath() splits into ['myMap', 'Key 0', '002mm'], resulting in nested object { myMap: { 'Key 0': { '002mm': value } } }

Expected: Path should be myMap.Key 0\.002mm, which splitPath() correctly splits into ['myMap', 'Key 0.002mm'], preserving the original structure { myMap: { 'Key 0.002mm': value } }

Impact

This causes data corruption during the flatten/unflatten cycle for Maps with dotted keys - the exact same issue the PR was trying to fix, but only fixed for regular objects.

(Refers to line 150)

Recommendation: Escape the Map key using escapeKey(): this.#processValue(value, \${prefix || "map"}.${escapeKey(keyStr)}`, depth);`

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,36 @@ export const CIRCULAR_REFERENCE_SENTINEL = "$@circular((";

const DEFAULT_MAX_DEPTH = 128;

function escapeKey(key: string) {
return key.replace(/\\/g, "\\\\").replace(/\./g, "\\.");
}

function splitPath(path: string): string[] {
const parts: string[] = [];
let current = "";
let isEscaped = false;

for (let i = 0; i < path.length; i++) {
const char = path[i];

if (isEscaped) {
current += char;
isEscaped = false;
} else if (char === "\\") {
isEscaped = true;
} else if (char === ".") {
parts.push(current);
current = "";
} else {
current += char;
}
}

parts.push(current);

return parts;
}
Comment on lines +12 to +36
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor edge case: trailing backslash is silently dropped.

If a path ends with an unescaped backslash (e.g., "key\\"), the isEscaped flag is set but no subsequent character exists to append. The trailing backslash is silently dropped.

This is unlikely to occur in practice since escapeKey only produces escaped sequences like \\\\ (for literal backslash) or \\. (for literal dot), never a dangling backslash. However, for defensive completeness, you could append the backslash if isEscaped is still true after the loop.

🛡️ Optional defensive fix
   parts.push(current);

+  if (isEscaped) {
+    // Handle trailing backslash (shouldn't occur with proper escaping)
+    parts[parts.length - 1] += "\\";
+  }
+
   return parts;
 }
🤖 Prompt for AI Agents
In `@packages/core/src/v3/utils/flattenAttributes.ts` around lines 12 - 36, The
splitPath function can drop a trailing unescaped backslash because isEscaped may
be true at loop end; fix it by detecting when isEscaped is still true after the
loop in splitPath and append a literal backslash (e.g., add "\\" to current)
before pushing the final segment, so paths like "key\\" preserve the trailing
backslash instead of silently losing it.


export function flattenAttributes(
obj: unknown,
prefix?: string,
Expand All @@ -24,7 +54,7 @@ class AttributeFlattener {
constructor(
private maxAttributeCount?: number,
private maxDepth: number = DEFAULT_MAX_DEPTH
) {}
) { }

get attributes(): Attributes {
return this.result;
Expand Down Expand Up @@ -200,7 +230,8 @@ class AttributeFlattener {
break;
}

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

if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
Expand Down Expand Up @@ -278,7 +309,7 @@ export function unflattenAttributes(
continue;
}

const parts = key.split(".").reduce(
const parts = splitPath(key).reduce(
(acc, part) => {
if (part.startsWith("[") && part.endsWith("]")) {
// Handle array indices more precisely
Expand Down
15 changes: 8 additions & 7 deletions packages/core/test/flattenAttributes.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { describe, it, expect } from "vitest";
import { flattenAttributes, unflattenAttributes } from "../src/v3/utils/flattenAttributes.js";

describe("flattenAttributes", () => {
Expand Down Expand Up @@ -297,9 +298,9 @@ describe("flattenAttributes", () => {
});

it("handles function values correctly", () => {
function namedFunction() {}
const anonymousFunction = function () {};
const arrowFunction = () => {};
function namedFunction() { }
const anonymousFunction = function () { };
const arrowFunction = () => { };

const result = flattenAttributes({
named: namedFunction,
Expand All @@ -317,7 +318,7 @@ describe("flattenAttributes", () => {
it("handles mixed problematic types", () => {
const complexObj = {
error: new Error("Mixed error"),
func: function testFunc() {},
func: function testFunc() { },
date: new Date("2023-01-01"),
normal: "string",
number: 42,
Expand Down Expand Up @@ -415,10 +416,10 @@ describe("flattenAttributes", () => {
it("handles Promise objects correctly", () => {
const resolvedPromise = Promise.resolve("value");
const rejectedPromise = Promise.reject(new Error("failed"));
const pendingPromise = new Promise(() => {}); // Never resolves
const pendingPromise = new Promise(() => { }); // Never resolves

// Catch the rejection to avoid unhandled promise rejection warnings
rejectedPromise.catch(() => {});
rejectedPromise.catch(() => { });

const result = flattenAttributes({
resolved: resolvedPromise,
Expand Down Expand Up @@ -481,7 +482,7 @@ describe("flattenAttributes", () => {
it("handles complex mixed object with all special types", () => {
const complexObj = {
error: new Error("Test error"),
func: function testFunc() {},
func: function testFunc() { },
date: new Date("2023-01-01"),
mySet: new Set([1, 2, 3]),
myMap: new Map([["key", "value"]]),
Expand Down