Improve the display of non-object return types in the run trace viewer
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Improve the display of non-object return types in the run trace viewer
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export {
|
||||
flattenAttributes,
|
||||
primitiveValueOrflattenedAttributes,
|
||||
unflattenAttributes,
|
||||
NULL_SENTINEL,
|
||||
} from "./utils/flattenAttributes";
|
||||
export { omit } from "./utils/omit";
|
||||
export {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user