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:
Matt Aitken
2024-02-23 09:54:01 +00:00
committed by GitHub
parent dd63fe6e9f
commit 07efef405d
19 changed files with 504 additions and 116 deletions
+2 -8
View File
@@ -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;
}
@@ -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))} />
@@ -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 })}`;
}
+2 -2
View File
@@ -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,
},
},
},
+16 -2
View File
@@ -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)
+19 -6
View File
@@ -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>;