v3 span details (#906)
* WIP on saving the resized state in a cookie * Progress on remembering the panel width * The resizable panel now remembers even when closed * Improvements to the span panel * Hide integrations in the v3 side menu * Added “UTC” to the times * WIP changing how styles work * Reference unflattenAttributes from the package * Unflatten now correctly unflattens arrays * Unflatten typecheck fix * Improvements to the spans and UI for them * Added the “events” to a span, including special styling for errors * Unflatten doesn’t work on an array of Attributes
This commit is contained in:
@@ -1,12 +1,6 @@
|
||||
import { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
EnvironmentLabel,
|
||||
environmentBorderClassName,
|
||||
environmentColorClassName,
|
||||
environmentTextClassName,
|
||||
environmentTitle,
|
||||
} from "./environments/EnvironmentLabel";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { environmentTextClassName, environmentTitle } from "./environments/EnvironmentLabel";
|
||||
|
||||
type Environment = Pick<RuntimeEnvironment, "type">;
|
||||
|
||||
@@ -20,7 +14,7 @@ export function VersionLabel({ environment, userName, version }: VersionLabelPro
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-stretch justify-items-stretch rounded-sm border border-midnight-700 text-xxs"
|
||||
"inline-flex items-center justify-stretch justify-items-stretch rounded-sm border border-midnight-700 text-xxs"
|
||||
)}
|
||||
>
|
||||
<div className="px-1 text-xs tabular-nums text-dimmed">v{version}</div>
|
||||
|
||||
@@ -138,13 +138,15 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
leadingIconClassName="text-indigo-500"
|
||||
/>
|
||||
</SideMenuHeader>
|
||||
<SideMenuItem
|
||||
name="Integrations"
|
||||
icon="integration"
|
||||
to={organizationIntegrationsPath(organization)}
|
||||
data-action="integrations"
|
||||
hasWarning={organization.hasUnconfiguredIntegrations}
|
||||
/>
|
||||
{project.version === "V2" && (
|
||||
<SideMenuItem
|
||||
name="Integrations"
|
||||
icon="integration"
|
||||
to={organizationIntegrationsPath(organization)}
|
||||
data-action="integrations"
|
||||
hasWarning={organization.hasUnconfiguredIntegrations}
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Projects"
|
||||
icon="folder"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { GripVertical } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
@@ -41,4 +40,4 @@ const ResizableHandle = ({
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
);
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { TaskEventStyle } from "@trigger.dev/core/v3";
|
||||
import { TaskEventLevel } from "@trigger.dev/database";
|
||||
|
||||
type Event = {
|
||||
isError: boolean;
|
||||
style: TaskEventStyle;
|
||||
level: TaskEventLevel;
|
||||
};
|
||||
|
||||
export function eventTextClassName(event: Event) {
|
||||
if (event.isError) {
|
||||
return "text-rose-500";
|
||||
}
|
||||
|
||||
switch (event.level) {
|
||||
case "TRACE": {
|
||||
return classNameForProminence(event.style.prominence);
|
||||
}
|
||||
case "LOG":
|
||||
case "INFO":
|
||||
case "DEBUG": {
|
||||
return classNameForProminence(event.style.prominence);
|
||||
}
|
||||
case "WARN": {
|
||||
return "text-amber-400";
|
||||
}
|
||||
case "ERROR": {
|
||||
return "text-rose-500";
|
||||
}
|
||||
default: {
|
||||
return classNameForProminence(event.style.prominence);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function classNameForProminence(prominence: TaskEventStyle["prominence"]) {
|
||||
return prominence === "high" ? "text-bright" : "text-dimmed";
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
import { formatDuration } from "@trigger.dev/core/v3";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function LiveTimer({
|
||||
startTime,
|
||||
endTime,
|
||||
updateInterval = 250,
|
||||
className,
|
||||
}: {
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
updateInterval?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const [now, setNow] = useState<Date>();
|
||||
|
||||
@@ -27,7 +30,7 @@ export function LiveTimer({
|
||||
}, [startTime]);
|
||||
|
||||
return (
|
||||
<Paragraph variant="extra-small" className="whitespace-nowrap tabular-nums">
|
||||
<Paragraph variant="extra-small" className={cn("whitespace-nowrap tabular-nums", className)}>
|
||||
{formatDuration(startTime, now, {
|
||||
style: "short",
|
||||
maxDecimalPoints: 0,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import type { OtelExceptionProperty, OtelSpanEvent } from "~/presenters/v3/SpanPresenter.server";
|
||||
|
||||
type SpanEventsProps = {
|
||||
spanEvents: OtelSpanEvent[];
|
||||
};
|
||||
|
||||
export function SpanEvents({ spanEvents }: SpanEventsProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{spanEvents.map((event, index) => (
|
||||
<SpanEvent key={index} spanEvent={event} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpanEventHeader({
|
||||
title,
|
||||
titleClassName,
|
||||
time,
|
||||
}: {
|
||||
title: string;
|
||||
titleClassName?: string;
|
||||
time: Date;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Header2 className={titleClassName}>{title}</Header2>
|
||||
<Paragraph variant="small">
|
||||
<DateTime date={time} />
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpanEvent({ spanEvent }: { spanEvent: OtelSpanEvent }) {
|
||||
if (spanEvent.properties?.exception) {
|
||||
return <SpanEventError spanEvent={spanEvent} exception={spanEvent.properties.exception} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<SpanEventHeader title={spanEvent.name} time={spanEvent.time} />
|
||||
{spanEvent.properties && (
|
||||
<CodeBlock code={JSON.stringify(spanEvent.properties, null, 2)} maxLines={20} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpanEventError({
|
||||
spanEvent,
|
||||
exception,
|
||||
}: {
|
||||
spanEvent: OtelSpanEvent;
|
||||
exception: OtelExceptionProperty;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-sm border border-rose-500/50 p-3">
|
||||
<SpanEventHeader title={"Error"} time={spanEvent.time} titleClassName="text-rose-500" />
|
||||
{exception.message && <Callout variant="error">{exception.message}</Callout>}
|
||||
{exception.stacktrace && <CodeBlock code={exception.stacktrace} maxLines={20} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import { Span } from "@opentelemetry/sdk-trace-base";
|
||||
import { TaskEventStyle } from "@trigger.dev/core/v3";
|
||||
import { TaskEventLevel } from "@trigger.dev/database";
|
||||
import { Fragment } from "react";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type SpanTitleProps = {
|
||||
message: string;
|
||||
isError: boolean;
|
||||
style: TaskEventStyle;
|
||||
level: TaskEventLevel;
|
||||
size: "small" | "large";
|
||||
};
|
||||
|
||||
export function SpanTitle(event: SpanTitleProps) {
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-2", eventTextClassName(event))}>
|
||||
{event.message} <SpanAccessory accessory={event.style.accessory} size={event.size} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SpanAccessory({
|
||||
accessory,
|
||||
size,
|
||||
}: {
|
||||
accessory: TaskEventStyle["accessory"];
|
||||
size: SpanTitleProps["size"];
|
||||
}) {
|
||||
if (!accessory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (accessory.style) {
|
||||
case "codepath": {
|
||||
return (
|
||||
<SpanCodePathAccessory
|
||||
accessory={accessory}
|
||||
className={cn(size === "large" ? "text-sm" : "text-xs")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return (
|
||||
<div className={cn("flex gap-1")}>
|
||||
{accessory.items.map((item, index) => (
|
||||
<span key={index} className={cn("inline-flex items-center gap-1")}>
|
||||
{item.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function SpanCodePathAccessory({
|
||||
accessory,
|
||||
className,
|
||||
}: {
|
||||
accessory: NonNullable<TaskEventStyle["accessory"]>;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
"inline-flex items-center gap-0.5 rounded border border-slate-800 bg-midnight-850 px-1.5 py-0.5 font-mono text-sky-200",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{accessory.items.map((item, index) => (
|
||||
<Fragment key={index}>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center",
|
||||
index === accessory.items.length - 1 ? "text-yellow-200" : "text-dimmed"
|
||||
)}
|
||||
>
|
||||
{item.text}
|
||||
</span>
|
||||
{index < accessory.items.length - 1 && (
|
||||
<span className="text-slate-500">
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
function eventTextClassName(event: SpanTitleProps) {
|
||||
if (event.isError) {
|
||||
return "text-rose-500";
|
||||
}
|
||||
|
||||
switch (event.level) {
|
||||
case "TRACE": {
|
||||
return classNameForVariant(event.style.variant);
|
||||
}
|
||||
case "LOG":
|
||||
case "INFO":
|
||||
case "DEBUG": {
|
||||
return classNameForVariant(event.style.variant);
|
||||
}
|
||||
case "WARN": {
|
||||
return "text-amber-400";
|
||||
}
|
||||
case "ERROR": {
|
||||
return "text-rose-500";
|
||||
}
|
||||
default: {
|
||||
return classNameForVariant(event.style.variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function classNameForVariant(variant: TaskEventStyle["variant"]) {
|
||||
switch (variant) {
|
||||
case "primary": {
|
||||
return "text-blue-500";
|
||||
}
|
||||
default: {
|
||||
return "text-dimmed";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { SpanCodePathAccessory } from "./SpanTitle";
|
||||
|
||||
type TaskPathProps = {
|
||||
filePath: string;
|
||||
functionName: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function TaskPath({ filePath, functionName, className }: TaskPathProps) {
|
||||
return (
|
||||
<SpanCodePathAccessory
|
||||
accessory={{
|
||||
items: [{ text: filePath }, { text: functionName }],
|
||||
}}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TaskEventStyle } from "@trigger.dev/core/v3";
|
||||
import { Attributes } from "@opentelemetry/api";
|
||||
import { TaskEventStyle, unflattenAttributes } from "@trigger.dev/core/v3";
|
||||
import { TaskEvent } from "@trigger.dev/database";
|
||||
import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/TreeView/TreeView";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
@@ -76,12 +77,15 @@ export class RunPresenter {
|
||||
|
||||
const tree = createTreeFromFlatItems(
|
||||
events.map((event) => {
|
||||
const styleUnflattened = unflattenAttributes(event.style as Attributes);
|
||||
const style = TaskEventStyle.parse(styleUnflattened);
|
||||
|
||||
return {
|
||||
id: event.spanId,
|
||||
parentId: event.parentId ?? undefined,
|
||||
data: {
|
||||
message: event.message,
|
||||
style: TaskEventStyle.parse(event.style),
|
||||
style,
|
||||
duration: Number(event.duration),
|
||||
isError: event.isError,
|
||||
isPartial: event.isPartial,
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
import { Attributes } from "@opentelemetry/api";
|
||||
import { TaskEventStyle } from "@trigger.dev/core/v3";
|
||||
import { unflattenAttributes } from "@trigger.dev/core/v3";
|
||||
import { z } from "zod";
|
||||
import { PrismaClient, prisma, Prisma } from "~/db.server";
|
||||
|
||||
type Result = Awaited<ReturnType<SpanPresenter["call"]>>;
|
||||
export type Span = Result["event"];
|
||||
|
||||
const OtelExceptionProperty = z.object({
|
||||
type: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
stacktrace: z.string().optional(),
|
||||
});
|
||||
|
||||
export type OtelExceptionProperty = z.infer<typeof OtelExceptionProperty>;
|
||||
|
||||
const OtelSpanEvent = z.object({
|
||||
name: z.string(),
|
||||
time: z.coerce.date(),
|
||||
properties: z
|
||||
.object({
|
||||
exception: OtelExceptionProperty.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const OtelSpanEvents = z.array(OtelSpanEvent).optional();
|
||||
|
||||
export type OtelSpanEvent = z.infer<typeof OtelSpanEvent>;
|
||||
|
||||
export class SpanPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
@@ -35,32 +59,68 @@ export class SpanPresenter {
|
||||
}
|
||||
|
||||
// Find the project scoped to the organization
|
||||
const events = await this.#prismaClient.taskEvent.findMany({
|
||||
const matchingEvents = await this.#prismaClient.taskEvent.findMany({
|
||||
where: {
|
||||
spanId,
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
const event = events.length > 1 ? events.find((event) => !event.isPartial) : events.at(0);
|
||||
const event =
|
||||
matchingEvents.length > 1
|
||||
? matchingEvents.find((event) => !event.isPartial)
|
||||
: matchingEvents.at(0);
|
||||
if (!event) {
|
||||
throw new Error("Span not found");
|
||||
}
|
||||
|
||||
const styleUnflattened = unflattenAttributes(event.style as Attributes);
|
||||
const style = TaskEventStyle.parse(styleUnflattened);
|
||||
|
||||
const eventsUnflattened = event.events
|
||||
? (event.events as any[]).map((e) => ({
|
||||
...e,
|
||||
properties: unflattenAttributes(e.properties as Attributes),
|
||||
}))
|
||||
: undefined;
|
||||
console.log("eventsUnflattened", eventsUnflattened);
|
||||
const events = OtelSpanEvents.parse(eventsUnflattened);
|
||||
|
||||
return {
|
||||
event: {
|
||||
...event,
|
||||
events,
|
||||
output: isEmptyJson(event.output) ? null : JSON.stringify(event.output, null, 2),
|
||||
properties: event.properties
|
||||
? JSON.stringify(unflattenAttributes(event.properties as Attributes), null, 2)
|
||||
: null,
|
||||
style: TaskEventStyle.parse(event.style),
|
||||
properties: sanitizedAttributesStringified(event.properties),
|
||||
style,
|
||||
duration: Number(event.duration),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizedAttributesStringified(json: Prisma.JsonValue): string | undefined {
|
||||
const sanitizedAttributesValue = sanitizedAttributes(json);
|
||||
if (!sanitizedAttributesValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
return JSON.stringify(sanitizedAttributesValue, null, 2);
|
||||
}
|
||||
|
||||
function sanitizedAttributes(json: Prisma.JsonValue): Record<string, unknown> | undefined {
|
||||
if (json === null || json === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const withoutPrivateProperties = removePrivateProperties(json as Attributes);
|
||||
if (!withoutPrivateProperties) {
|
||||
return;
|
||||
}
|
||||
|
||||
return unflattenAttributes(withoutPrivateProperties);
|
||||
}
|
||||
|
||||
function isEmptyJson(json: Prisma.JsonValue) {
|
||||
if (json === null) {
|
||||
return true;
|
||||
@@ -71,3 +131,28 @@ function isEmptyJson(json: Prisma.JsonValue) {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// removes keys that start with a $ sign. If there are no keys left, return undefined
|
||||
function removePrivateProperties(
|
||||
attributes: Attributes | undefined | null
|
||||
): Attributes | undefined {
|
||||
if (!attributes) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: Attributes = {};
|
||||
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
if (key.startsWith("$")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
if (Object.keys(result).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
+40
-15
@@ -2,18 +2,22 @@ import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { formatDurationNanoseconds, nanosecondsToMilliseconds } from "@trigger.dev/core/v3";
|
||||
import { ReactNode } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { VersionLabel } from "~/components/VersionLabel";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
|
||||
import { eventTextClassName } from "~/components/runs/v3/EventText";
|
||||
import { SpanTitle } from "~/components/runs/v3/SpanTitle";
|
||||
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
|
||||
import { RunIcon } from "~/components/runs/v3/RunIcon";
|
||||
import { SpanPresenter } from "~/presenters/v3/SpanPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { v3SpanParamsSchema } from "~/utils/pathBuilder";
|
||||
import { TaskPath } from "~/components/runs/v3/TaskPath";
|
||||
import { SpanEvents } from "~/components/runs/v3/SpanEvents";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -41,8 +45,8 @@ export default function Page() {
|
||||
<div className="flex h-8 items-center justify-between gap-2 border-b border-ui-border px-2">
|
||||
<div className="flex items-center gap-1 overflow-x-hidden">
|
||||
<RunIcon name={event.style?.icon} className="min-w-4 min-h-4 h-4 w-4" />
|
||||
<Header2 className={cn("whitespace-nowrap", eventTextClassName(event))}>
|
||||
{event.message}
|
||||
<Header2 className={cn("whitespace-nowrap")}>
|
||||
<SpanTitle {...event} size="large" />
|
||||
</Header2>
|
||||
</div>
|
||||
<ShortcutKey shortcut={{ key: "esc" }} variant="small" />
|
||||
@@ -52,7 +56,7 @@ export default function Page() {
|
||||
<div className="flex flex-col gap-4">
|
||||
<PropertyTable>
|
||||
{event.level === "TRACE" ? (
|
||||
<Property label="Timeline">
|
||||
<Property label="Timeline" labelClassName="self-end">
|
||||
<Timeline
|
||||
startTime={new Date(event.startTime)}
|
||||
duration={event.duration}
|
||||
@@ -63,20 +67,42 @@ export default function Page() {
|
||||
) : (
|
||||
<Property label="Timestamp">
|
||||
<Paragraph variant="small/bright">
|
||||
<DateTime date={event.startTime} />
|
||||
<DateTime date={event.startTime} /> UTC
|
||||
</Paragraph>
|
||||
</Property>
|
||||
)}
|
||||
<Property label="Message">{event.message}</Property>
|
||||
<Property label="Task ID">{event.taskSlug}</Property>
|
||||
{event.taskPath && event.taskExportName && (
|
||||
<Property label="Task">
|
||||
<TaskPath
|
||||
filePath={event.taskPath}
|
||||
functionName={`${event.taskExportName}()`}
|
||||
className="text-xs"
|
||||
/>
|
||||
</Property>
|
||||
)}
|
||||
|
||||
{event.queueName && <Property label="Queue name">{event.queueName}</Property>}
|
||||
{event.workerVersion && (
|
||||
<Property label="Version">
|
||||
<VersionLabel
|
||||
version={event.workerVersion}
|
||||
environment={{ type: event.environmentType }}
|
||||
/>
|
||||
</Property>
|
||||
)}
|
||||
</PropertyTable>
|
||||
|
||||
{event.events !== undefined && <SpanEvents spanEvents={event.events} />}
|
||||
|
||||
{event.output !== null && (
|
||||
<div>
|
||||
<Header2 spacing>Output</Header2>
|
||||
<CodeBlock code={event.output} maxLines={20} />
|
||||
</div>
|
||||
)}
|
||||
{event.properties !== null && (
|
||||
{event.properties !== undefined && (
|
||||
<div>
|
||||
<Header2 spacing>Properties</Header2>
|
||||
<CodeBlock code={event.properties} maxLines={20} />
|
||||
@@ -89,18 +115,19 @@ export default function Page() {
|
||||
}
|
||||
|
||||
function PropertyTable({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <div className="grid grid-cols-[auto,1fr] gap-x-4 gap-y-2">{children}</div>;
|
||||
return <div className="grid grid-cols-[auto,1fr] items-baseline gap-x-4 gap-y-2">{children}</div>;
|
||||
}
|
||||
|
||||
type PropertyProps = {
|
||||
label: ReactNode;
|
||||
labelClassName?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function Property({ label, children }: PropertyProps) {
|
||||
function Property({ label, labelClassName, children }: PropertyProps) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className={labelClassName}>
|
||||
{typeof label === "string" ? <Paragraph variant="small">{label}</Paragraph> : label}
|
||||
</div>
|
||||
<div>
|
||||
@@ -127,14 +154,12 @@ function Timeline({ startTime, duration, inProgress, isError }: TimelineProps) {
|
||||
const state = isError ? "error" : inProgress ? "pending" : "complete";
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<Paragraph variant="small">
|
||||
<DateTime date={startTime} />
|
||||
<DateTime date={startTime} /> UTC
|
||||
</Paragraph>
|
||||
{state === "pending" ? (
|
||||
<Paragraph variant="small">
|
||||
<LiveTimer startTime={startTime} />
|
||||
</Paragraph>
|
||||
<LiveTimer startTime={startTime} className="" />
|
||||
) : (
|
||||
<Paragraph variant="small">
|
||||
<DateTime date={new Date(startTime.getTime() + nanosecondsToMilliseconds(duration))} />
|
||||
|
||||
+58
-24
@@ -11,7 +11,12 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { ShowParentIcon, ShowParentIconSelected } from "~/assets/icons/ShowParentIcon";
|
||||
import { PageBody } from "~/components/layout/AppLayout";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { PageHeader, PageTitle, PageTitleRow } from "~/components/primitives/PageHeader";
|
||||
import {
|
||||
PageButtons,
|
||||
PageHeader,
|
||||
PageTitle,
|
||||
PageTitleRow,
|
||||
} from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
ResizableHandle,
|
||||
@@ -21,7 +26,7 @@ import {
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import { TreeView, useTree } from "~/components/primitives/TreeView/TreeView";
|
||||
import { eventTextClassName } from "~/components/runs/v3/EventText";
|
||||
import { SpanTitle } from "~/components/runs/v3/SpanTitle";
|
||||
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
|
||||
import { RunIcon } from "~/components/runs/v3/RunIcon";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
@@ -30,9 +35,12 @@ import { usePathName } from "~/hooks/usePathName";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useThrottle } from "~/hooks/useThrottle";
|
||||
import { RunEvent, RunPresenter } from "~/presenters/v3/RunPresenter.server";
|
||||
import { getResizableRunSettings, setResizableRunSettings } from "~/services/resizablePanel";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { v3RunParamsSchema, v3RunPath, v3RunSpanPath } from "~/utils/pathBuilder";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -46,10 +54,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
runFriendlyId: runParam,
|
||||
});
|
||||
|
||||
//resizable settings
|
||||
const resizeSettings = await getResizableRunSettings(request);
|
||||
|
||||
return typedjson({
|
||||
run,
|
||||
events,
|
||||
parentRunFriendlyId,
|
||||
resizeSettings,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -60,11 +72,12 @@ function getSpanId(path: string): string | undefined {
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { run, events, parentRunFriendlyId } = useTypedLoaderData<typeof loader>();
|
||||
const { run, events, parentRunFriendlyId, resizeSettings } = useTypedLoaderData<typeof loader>();
|
||||
const navigate = useNavigate();
|
||||
const organization = useOrganization();
|
||||
const pathName = usePathName();
|
||||
const project = useProject();
|
||||
const user = useUser();
|
||||
|
||||
const selectedSpanId = getSpanId(pathName);
|
||||
|
||||
@@ -72,18 +85,46 @@ export default function Page() {
|
||||
navigate(v3RunSpanPath(organization, project, run, { spanId: selectedSpan }));
|
||||
}, 250);
|
||||
|
||||
const usernameForEnv = user.id !== run.environment.userId ? run.environment.userName : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader hideBorder>
|
||||
<PageTitleRow>
|
||||
<PageTitle title={`Run #${run.number}`} />
|
||||
<PageButtons>
|
||||
<EnvironmentLabel environment={run.environment} userName={usernameForEnv} />
|
||||
</PageButtons>
|
||||
</PageTitleRow>
|
||||
</PageHeader>
|
||||
<PageBody scrollable={false}>
|
||||
<div className={cn("grid h-full max-h-full grid-cols-1 gap-4")}>
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full max-h-full">
|
||||
<ResizablePanel order={1} minSize={30}>
|
||||
<div className="h-full overflow-y-clip">
|
||||
<div className={cn("grid h-full max-h-full grid-cols-1")}>
|
||||
{selectedSpanId === undefined ? (
|
||||
<TasksTreeView
|
||||
selectedId={selectedSpanId}
|
||||
key={events[0]?.id ?? "-"}
|
||||
events={events}
|
||||
parentRunFriendlyId={parentRunFriendlyId}
|
||||
onSelectedIdChanged={(selectedSpan) => {
|
||||
//instantly close the panel if no span is selected
|
||||
if (!selectedSpan) {
|
||||
navigate(v3RunPath(organization, project, run));
|
||||
return;
|
||||
}
|
||||
|
||||
changeToSpan(selectedSpan);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full max-h-full"
|
||||
onLayout={(layout) => {
|
||||
if (layout.length !== 2) return;
|
||||
setResizableRunSettings(document, layout);
|
||||
}}
|
||||
>
|
||||
<ResizablePanel order={1} minSize={30} defaultSize={resizeSettings.layout?.[0]}>
|
||||
<TasksTreeView
|
||||
selectedId={selectedSpanId}
|
||||
key={events[0]?.id ?? "-"}
|
||||
@@ -99,17 +140,13 @@ export default function Page() {
|
||||
changeToSpan(selectedSpan);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
{selectedSpanId !== undefined && (
|
||||
<>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel order={2} minSize={30} defaultSize={40}>
|
||||
<Outlet key={selectedSpanId} />
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel order={2} minSize={30} defaultSize={resizeSettings.layout?.[1]}>
|
||||
<Outlet key={selectedSpanId} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)}
|
||||
</div>
|
||||
</PageBody>
|
||||
</>
|
||||
@@ -160,7 +197,7 @@ function TasksTreeView({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="px-3">
|
||||
<div className="h-full overflow-y-clip px-3">
|
||||
<div className="flex h-8 items-center justify-between gap-2 border-b border-slate-850">
|
||||
<Input
|
||||
placeholder="Search log"
|
||||
@@ -261,11 +298,8 @@ function TasksTreeView({
|
||||
function NodeText({ node }: { node: RunEvent }) {
|
||||
const className = "truncate";
|
||||
return (
|
||||
<Paragraph
|
||||
variant="small"
|
||||
className={cn(className, eventTextClassName(node.data), node.data.isError && "text-rose-500")}
|
||||
>
|
||||
{node.data.message}
|
||||
<Paragraph variant="small" className={cn(className)}>
|
||||
<SpanTitle {...node.data} size="small" />
|
||||
</Paragraph>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const ResizableConfig = z
|
||||
.object({ layout: z.array(z.number()).optional() })
|
||||
.default({ layout: undefined });
|
||||
type ResizableConfig = z.infer<typeof ResizableConfig>;
|
||||
|
||||
function getCookieValue(cookieHeader: string | null, cookieName: string): ResizableConfig {
|
||||
const cookieValue = cookieHeader?.split(`${cookieName}=`)[1]?.split(";")[0];
|
||||
if (!cookieValue) {
|
||||
return { layout: undefined };
|
||||
}
|
||||
try {
|
||||
const json = JSON.parse(cookieValue);
|
||||
return ResizableConfig.parse(json);
|
||||
} catch (e) {
|
||||
return { layout: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
//run page
|
||||
const runResizableName = "resizable-panels:run";
|
||||
|
||||
export async function getResizableRunSettings(request: Request): Promise<ResizableConfig> {
|
||||
const cookieHeader = request.headers.get("Cookie");
|
||||
return getCookieValue(cookieHeader, runResizableName);
|
||||
}
|
||||
|
||||
export async function setResizableRunSettings(document: Document, layout: number[]) {
|
||||
document.cookie = `${runResizableName}=${JSON.stringify({ layout })}`;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { PrismaClient, prisma } from "~/db.server";
|
||||
import { RandomIdGenerator } from "@opentelemetry/sdk-trace-base";
|
||||
import { Attributes, ROOT_CONTEXT, propagation, trace } from "@opentelemetry/api";
|
||||
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
|
||||
import { HIGH_PROMINENCE, SemanticInternalAttributes } from "@trigger.dev/core/v3";
|
||||
import { SemanticInternalAttributes, PRIMARY_VARIANT } from "@trigger.dev/core/v3";
|
||||
import { flattenAttributes } from "@trigger.dev/core/v3";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { DynamicFlushScheduler } from "./dynamicFlushScheduler.server";
|
||||
@@ -230,7 +230,7 @@ export class EventRepository {
|
||||
|
||||
const style = {
|
||||
[SemanticInternalAttributes.STYLE_ICON]: "task",
|
||||
[SemanticInternalAttributes.STYLE_PROMINENCE]: HIGH_PROMINENCE,
|
||||
[SemanticInternalAttributes.STYLE_VARIANT]: PRIMARY_VARIANT,
|
||||
};
|
||||
|
||||
if (!options.attributes.runId) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
||||
import { nanoid } from "nanoid";
|
||||
import { $transaction, PrismaClient, prisma } from "~/db.server";
|
||||
import {
|
||||
PRIMARY_VARIANT,
|
||||
SemanticInternalAttributes,
|
||||
TriggerTaskRequestBody,
|
||||
flattenAttributes,
|
||||
@@ -45,7 +46,7 @@ export class TriggerTaskService extends BaseService {
|
||||
}
|
||||
|
||||
return await eventRepository.traceEvent(
|
||||
`Triggering task ${taskId}`,
|
||||
`${taskId}`,
|
||||
{
|
||||
context: options.traceContext,
|
||||
kind: "SERVER",
|
||||
@@ -57,6 +58,7 @@ export class TriggerTaskService extends BaseService {
|
||||
},
|
||||
style: {
|
||||
icon: "play",
|
||||
variant: PRIMARY_VARIANT,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
workerToChildMessages,
|
||||
TaskRunExecutionRetry,
|
||||
calculateNextRetryTimestamp,
|
||||
Accessory,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import * as packageJson from "../package.json";
|
||||
|
||||
@@ -97,7 +98,7 @@ class TaskExecutor {
|
||||
) {
|
||||
const parsedPayload = JSON.parse(execution.run.payload);
|
||||
const ctx = TaskRunContext.parse(execution);
|
||||
const attemptMessage = `Attempt #${execution.attempt.number}`;
|
||||
const attemptMessage = `Attempt ${execution.attempt.number}`;
|
||||
|
||||
const output = await taskContextManager.runWith(
|
||||
{
|
||||
@@ -112,13 +113,25 @@ class TaskExecutor {
|
||||
[SemanticInternalAttributes.SDK_LANGUAGE]: "typescript",
|
||||
});
|
||||
|
||||
const accessory: Accessory = {
|
||||
items: [
|
||||
{
|
||||
text: ctx.task.filePath,
|
||||
},
|
||||
{
|
||||
text: `${ctx.task.exportName}()`,
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
};
|
||||
|
||||
return await tracer.startActiveSpan(
|
||||
attemptMessage,
|
||||
async (span) => {
|
||||
return await consoleInterceptor.intercept(console, async () => {
|
||||
const output = await this.task.run({
|
||||
payload: parsedPayload,
|
||||
ctx: TaskRunContext.parse(execution),
|
||||
ctx,
|
||||
});
|
||||
|
||||
span.setAttributes(flattenAttributes(output, SemanticInternalAttributes.OUTPUT));
|
||||
@@ -130,6 +143,7 @@ class TaskExecutor {
|
||||
kind: SpanKind.CONSUMER,
|
||||
attributes: {
|
||||
[SemanticInternalAttributes.STYLE_ICON]: "attempt",
|
||||
...flattenAttributes(accessory, SemanticInternalAttributes.STYLE_ACCESSORY),
|
||||
},
|
||||
},
|
||||
tracer.extractContext(traceContext)
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const LOW_PROMINENCE = "low";
|
||||
export const HIGH_PROMINENCE = "high";
|
||||
export const PRIMARY_VARIANT = "primary";
|
||||
|
||||
const Prominence = z.enum([LOW_PROMINENCE, HIGH_PROMINENCE]);
|
||||
export type Prominence = z.infer<typeof Prominence>;
|
||||
const Variant = z.enum([PRIMARY_VARIANT]);
|
||||
export type Variant = z.infer<typeof Variant>;
|
||||
|
||||
const AccessoryItem = z.object({
|
||||
text: z.string(),
|
||||
variant: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
});
|
||||
|
||||
const Accessory = z.object({
|
||||
items: z.array(AccessoryItem),
|
||||
style: z.enum(["codepath"]).optional(),
|
||||
});
|
||||
|
||||
export type Accessory = z.infer<typeof Accessory>;
|
||||
|
||||
export const TaskEventStyle = z
|
||||
.object({
|
||||
icon: z.string().optional(),
|
||||
prominence: Prominence.default(LOW_PROMINENCE),
|
||||
variant: Variant.optional(),
|
||||
accessory: Accessory.optional(),
|
||||
})
|
||||
.default({
|
||||
icon: undefined,
|
||||
prominence: LOW_PROMINENCE,
|
||||
variant: undefined,
|
||||
});
|
||||
|
||||
export type TaskEventStyle = z.infer<typeof TaskEventStyle>;
|
||||
|
||||
@@ -20,7 +20,8 @@ export const SemanticInternalAttributes = {
|
||||
OUTPUT: "$output",
|
||||
STYLE: "$style",
|
||||
STYLE_ICON: "$style.icon",
|
||||
STYLE_PROMINENCE: "$style.prominence",
|
||||
STYLE_VARIANT: "$style.variant",
|
||||
STYLE_ACCESSORY: "$style.accessory",
|
||||
METADATA: "$metadata",
|
||||
TRIGGER: "$trigger",
|
||||
PAYLOAD: "$payload",
|
||||
|
||||
@@ -65,8 +65,10 @@ export function unflattenAttributes(obj: Attributes): Record<string, unknown> {
|
||||
|
||||
// Check if part is not undefined and it's a string.
|
||||
if (typeof part === "string") {
|
||||
const nextPart = parts[i + 1];
|
||||
const isArray = nextPart ? parseInt(nextPart, 10).toString() === nextPart : false;
|
||||
if (current[part] == null) {
|
||||
current[part] = {};
|
||||
current[part] = isArray ? [] : {};
|
||||
}
|
||||
|
||||
current = current[part] as Record<string, unknown>;
|
||||
|
||||
Reference in New Issue
Block a user