Improve the display of non-object return types in the run trace viewer

This commit is contained in:
Eric Allam
2024-05-01 13:52:01 +01:00
parent ed03f4bc15
commit 9e5382951b
10 changed files with 197 additions and 52 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Improve the display of non-object return types in the run trace viewer
+1 -2
View File
@@ -1,5 +1,4 @@
import { json, Session } from "@remix-run/node";
import { redirect } from "remix-typedjson";
import { json, redirect, Session } from "@remix-run/node";
import { createCookieSessionStorage } from "@remix-run/node";
import { env } from "~/env.server";
@@ -42,7 +42,7 @@ export class SpanPresenter {
const output =
span.outputType === "application/store"
? `/resources/packets/${span.environmentId}/${span.output}`
: typeof span.output !== "undefined" && span.output !== null
: typeof span.output !== "undefined"
? await prettyPrintPacket(span.output, span.outputType ?? undefined)
: undefined;
@@ -63,6 +63,14 @@ export const action: ActionFunction = async ({ request, params }) => {
{ spanId: newRun.spanId }
);
logger.debug("Replayed run", {
taskRunId: taskRun.id,
taskRunFriendlyId: taskRun.friendlyId,
newRunId: newRun.id,
newRunFriendlyId: newRun.friendlyId,
runPath,
});
return redirectWithSuccessMessage(runPath, request, `Replaying run`);
} catch (error) {
if (error instanceof Error) {
+59 -15
View File
@@ -13,6 +13,7 @@ import {
correctErrorStackTrace,
createPacketAttributesAsJson,
flattenAttributes,
NULL_SENTINEL,
isExceptionSpanEvent,
omit,
unflattenAttributes,
@@ -438,21 +439,10 @@ export class EventRepository {
return;
}
const output = isEmptyJson(fullEvent.output)
? null
: unflattenAttributes(fullEvent.output as Attributes);
const output = rehydrateJson(fullEvent.output);
const payload = rehydrateJson(fullEvent.payload);
const payload = isEmptyJson(fullEvent.payload)
? null
: unflattenAttributes(fullEvent.payload as Attributes);
const show = unflattenAttributes(
filteredAttributes(fullEvent.properties as Attributes, SemanticInternalAttributes.SHOW)
)[SemanticInternalAttributes.SHOW] as
| {
actions?: boolean;
}
| undefined;
const show = rehydrateShow(fullEvent.properties);
const properties = sanitizedAttributes(fullEvent.properties);
@@ -1046,7 +1036,7 @@ function isEmptyJson(json: Prisma.JsonValue) {
return false;
}
function sanitizedAttributes(json: Prisma.JsonValue): Record<string, unknown> | undefined {
function sanitizedAttributes(json: Prisma.JsonValue) {
if (json === null || json === undefined) {
return;
}
@@ -1143,3 +1133,57 @@ function getNowInNanoseconds(): bigint {
function getDateFromNanoseconds(nanoseconds: bigint) {
return new Date(Number(nanoseconds) / 1_000_000);
}
function rehydrateJson(json: Prisma.JsonValue): any {
if (json === null) {
return undefined;
}
if (json === NULL_SENTINEL) {
return null;
}
if (typeof json === "string") {
return json;
}
if (typeof json === "number") {
return json;
}
if (typeof json === "boolean") {
return json;
}
if (Array.isArray(json)) {
return json.map((item) => rehydrateJson(item));
}
if (typeof json === "object") {
return unflattenAttributes(json as Attributes);
}
return null;
}
function rehydrateShow(properties: Prisma.JsonValue): { actions?: boolean } | undefined {
if (properties === null || properties === undefined) {
return;
}
if (typeof properties !== "object") {
return;
}
if (Array.isArray(properties)) {
return;
}
const actions = properties[SemanticInternalAttributes.SHOW_ACTIONS];
if (typeof actions === "boolean") {
return { actions };
}
return;
}
+1
View File
@@ -28,6 +28,7 @@ export {
flattenAttributes,
primitiveValueOrflattenedAttributes,
unflattenAttributes,
NULL_SENTINEL,
} from "./utils/flattenAttributes";
export { omit } from "./utils/omit";
export {
+64 -29
View File
@@ -1,5 +1,7 @@
import { Attributes } from "@opentelemetry/api";
export const NULL_SENTINEL = "$@null((";
export function flattenAttributes(
obj: Record<string, unknown> | Array<unknown> | string | boolean | number | null | undefined,
prefix?: string
@@ -7,7 +9,12 @@ export function flattenAttributes(
const result: Attributes = {};
// Check if obj is null or undefined
if (!obj) {
if (obj === undefined) {
return result;
}
if (obj === null) {
result[prefix || ""] = NULL_SENTINEL;
return result;
}
@@ -27,14 +34,18 @@ export function flattenAttributes(
}
for (const [key, value] of Object.entries(obj)) {
const newPrefix = `${prefix ? `${prefix}.` : ""}${key}`;
const newPrefix = `${prefix ? `${prefix}.` : ""}${Array.isArray(obj) ? `[${key}]` : key}`;
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
if (typeof value[i] === "object" && value[i] !== null) {
// update null check here as well
Object.assign(result, flattenAttributes(value[i], `${newPrefix}.[${i}]`));
} else {
result[`${newPrefix}.[${i}]`] = value[i];
if (value[i] === null) {
result[`${newPrefix}.[${i}]`] = NULL_SENTINEL;
} else {
result[`${newPrefix}.[${i}]`] = value[i];
}
}
}
} else if (isRecord(value)) {
@@ -43,6 +54,8 @@ export function flattenAttributes(
} else {
if (typeof value === "number" || typeof value === "string" || typeof value === "boolean") {
result[newPrefix] = value;
} else if (value === null) {
result[newPrefix] = NULL_SENTINEL;
}
}
}
@@ -54,55 +67,69 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
export function unflattenAttributes(obj: Attributes): Record<string, unknown> {
export function unflattenAttributes(
obj: Attributes
): Record<string, unknown> | string | number | boolean | null | undefined {
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
return obj;
}
if (
typeof obj === "object" &&
obj !== null &&
Object.keys(obj).length === 1 &&
Object.keys(obj)[0] === ""
) {
return rehydrateNull(obj[""]) as any;
}
if (Object.keys(obj).length === 0) {
return;
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
const parts = key.split(".").reduce((acc, part) => {
// Splitting array indices as separate parts
if (detectIsArrayIndex(part)) {
acc.push(part);
if (part.includes("[")) {
// Handling nested array indices
const subparts = part.split(/\[|\]/).filter((p) => p !== "");
acc.push(...subparts);
} else {
acc.push(...part.split(/\.\[(.*?)\]/).filter(Boolean));
acc.push(part);
}
return acc;
}, [] as string[]);
let current: Record<string, unknown> = result;
let current: any = result;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
const isArray = detectIsArrayIndex(part);
const cleanPart = isArray ? part.substring(1, part.length - 1) : part;
const nextIsArray = detectIsArrayIndex(parts[i + 1]);
if (!current[cleanPart]) {
current[cleanPart] = nextIsArray ? [] : {};
const nextPart = parts[i + 1];
const isArray = /^\d+$/.test(nextPart);
if (isArray && !Array.isArray(current[part])) {
current[part] = [];
} else if (!isArray && current[part] === undefined) {
current[part] = {};
}
current = current[cleanPart] as Record<string, unknown>;
current = current[part];
}
const lastPart = parts[parts.length - 1];
const cleanLastPart = detectIsArrayIndex(lastPart)
? parseInt(lastPart.substring(1, lastPart.length - 1), 10)
: lastPart;
current[cleanLastPart] = value;
current[lastPart] = rehydrateNull(value);
}
// Convert the result to an array if all top-level keys are numeric indices
if (Object.keys(result).every((k) => /^\d+$/.test(k))) {
const maxIndex = Math.max(...Object.keys(result).map((k) => parseInt(k)));
const arrayResult = Array(maxIndex + 1);
for (const key in result) {
arrayResult[parseInt(key)] = result[key];
}
return arrayResult as any;
}
return result;
}
function detectIsArrayIndex(key: string): boolean {
const match = key.match(/^\[(\d+)\]$/);
if (match) {
return true;
}
return false;
}
export function primitiveValueOrflattenedAttributes(
obj: Record<string, unknown> | Array<unknown> | string | boolean | number | undefined,
prefix: string | undefined
@@ -129,3 +156,11 @@ export function primitiveValueOrflattenedAttributes(
return attributes;
}
function rehydrateNull(value: any): any {
if (value === NULL_SENTINEL) {
return null;
}
return value;
}
@@ -216,11 +216,13 @@ export async function createPacketAttributes(
const parsed = parse(packet.data) as any;
const jsonified = JSON.parse(JSON.stringify(parsed, safeReplacer));
return {
const result = {
...flattenAttributes(jsonified, dataKey),
[dataTypeKey]: "application/json",
};
} catch {
return result;
} catch (e) {
return;
}
+29 -3
View File
@@ -1,24 +1,50 @@
import { flattenAttributes, unflattenAttributes } from "../src/v3/utils/flattenAttributes";
describe("flattenAttributes", () => {
it("handles null and undefined gracefully", () => {
expect(flattenAttributes(null)).toEqual({});
expect(flattenAttributes(undefined)).toEqual({});
it("handles null correctly", () => {
expect(flattenAttributes(null)).toEqual({ "": "$@null((" });
expect(unflattenAttributes({ "": "$@null((" })).toEqual(null);
expect(flattenAttributes(null, "$output")).toEqual({ $output: "$@null((" });
expect(flattenAttributes({ foo: null })).toEqual({ foo: "$@null((" });
expect(unflattenAttributes({ foo: "$@null((" })).toEqual({ foo: null });
expect(flattenAttributes({ foo: [null] })).toEqual({ "foo.[0]": "$@null((" });
expect(unflattenAttributes({ "foo.[0]": "$@null((" })).toEqual({ foo: [null] });
expect(flattenAttributes([null])).toEqual({ "[0]": "$@null((" });
expect(unflattenAttributes({ "[0]": "$@null((" })).toEqual([null]);
});
it("flattens string attributes correctly", () => {
const result = flattenAttributes("testString");
expect(result).toEqual({ "": "testString" });
expect(unflattenAttributes(result)).toEqual("testString");
});
it("flattens number attributes correctly", () => {
const result = flattenAttributes(12345);
expect(result).toEqual({ "": 12345 });
expect(unflattenAttributes(result)).toEqual(12345);
});
it("flattens boolean attributes correctly", () => {
const result = flattenAttributes(true);
expect(result).toEqual({ "": true });
expect(unflattenAttributes(result)).toEqual(true);
});
it("flattens boolean attributes correctly", () => {
const result = flattenAttributes(true, "$output");
expect(result).toEqual({ $output: true });
expect(unflattenAttributes(result)).toEqual({ $output: true });
});
it("flattens array attributes correctly", () => {
const input = [1, 2, 3];
const result = flattenAttributes(input);
expect(result).toEqual({ "[0]": 1, "[1]": 2, "[2]": 3 });
expect(unflattenAttributes(result)).toEqual(input);
});
it("flattens complex objects correctly", () => {
@@ -1,5 +1,30 @@
import { task } from "@trigger.dev/sdk/v3";
export const returnAllTypes = task({
id: "return-all-types",
run: async () => {
const resultString = await returnString.triggerAndWait();
const resultNumber = await returnNumber.triggerAndWait();
const resultTrue = await returnTrue.triggerAndWait();
const resultFalse = await returnFalse.triggerAndWait();
const resultNull = await returnNull.triggerAndWait();
const resultUndefined = await returnUndefined.triggerAndWait();
const resultObject = await returnObject.triggerAndWait();
const resultArray = await returnArray.triggerAndWait();
return {
resultString,
resultNumber,
resultTrue,
resultFalse,
resultNull,
resultUndefined,
resultObject,
resultArray,
};
},
});
export const returnString = task({
id: "return-string",
run: async () => {