diff --git a/app/src/components/trace/index.tsx b/app/src/components/trace/index.tsx new file mode 100644 index 0000000000..149716a088 --- /dev/null +++ b/app/src/components/trace/index.tsx @@ -0,0 +1 @@ +export * from "./SpanKindIcon"; diff --git a/app/src/openInference/tracing/types.ts b/app/src/openInference/tracing/types.ts index 3621ae5c42..c68279c7e3 100644 --- a/app/src/openInference/tracing/types.ts +++ b/app/src/openInference/tracing/types.ts @@ -29,6 +29,7 @@ export type AttributeToolCall = { export type AttributeMessages = { [SemanticAttributePrefixes.message]?: AttributeMessage; }[]; + export type AttributeMessage = { [MessageAttributePostfixes.role]?: string; [MessageAttributePostfixes.content]?: string; diff --git a/app/src/pages/trace/SpanDetails.tsx b/app/src/pages/trace/SpanDetails.tsx new file mode 100644 index 0000000000..00e0644705 --- /dev/null +++ b/app/src/pages/trace/SpanDetails.tsx @@ -0,0 +1,1597 @@ +import React, { + PropsWithChildren, + ReactNode, + Suspense, + useCallback, + useMemo, + useState, +} from "react"; +import { useNavigate } from "react-router"; +import { json } from "@codemirror/lang-json"; +import { nord } from "@uiw/codemirror-theme-nord"; +import { EditorView } from "@uiw/react-codemirror"; +import CodeMirror from "@uiw/react-codemirror"; +import { css } from "@emotion/react"; + +import { + Alert, + Button, + Card, + CardProps, + Content, + ContextualHelp, + Counter, + DialogContainer, + EmptyGraphic, + Flex, + Heading, + Icon, + Icons, + Label, + LabelProps, + List, + ListItem, + TabbedCard, + TabPane, + Tabs, + Text, + View, + ViewProps, + ViewStyleProps, +} from "@arizeai/components"; +import { + DocumentAttributePostfixes, + EmbeddingAttributePostfixes, + LLMAttributePostfixes, + MessageAttributePostfixes, + RerankerAttributePostfixes, + RetrievalAttributePostfixes, + SemanticAttributePrefixes, + ToolAttributePostfixes, +} from "@arizeai/openinference-semantic-conventions"; + +import { CopyToClipboardButton, ExternalLink } from "@phoenix/components"; +import { ErrorBoundary } from "@phoenix/components/ErrorBoundary"; +import { + ConnectedMarkdownBlock, + ConnectedMarkdownModeRadioGroup, + MarkdownDisplayProvider, +} from "@phoenix/components/markdown"; +import { SpanKindIcon } from "@phoenix/components/trace"; +import { SpanItem } from "@phoenix/components/trace/SpanItem"; +import { useNotifySuccess, useTheme } from "@phoenix/contexts"; +import { useFeatureFlag } from "@phoenix/contexts/FeatureFlagsContext"; +import { + AttributeDocument, + AttributeEmbedding, + AttributeEmbeddingEmbedding, + AttributeLlm, + AttributeMessage, + AttributeMessageContent, + AttributePromptTemplate, + AttributeReranker, + AttributeRetrieval, + AttributeTool, +} from "@phoenix/openInference/tracing/types"; +import { assertUnreachable, isStringArray } from "@phoenix/typeUtils"; +import { formatFloat, numberFormatter } from "@phoenix/utils/numberFormatUtils"; + +import { RetrievalEvaluationLabel } from "../project/RetrievalEvaluationLabel"; + +import { + MimeType, + TraceDetailsQuery$data, +} from "./__generated__/TraceDetailsQuery.graphql"; +import { EditSpanAnnotationsButton } from "./EditSpanAnnotationsButton"; +import { SpanCodeDropdown } from "./SpanCodeDropdown"; +import { SpanEvaluationsTable } from "./SpanEvaluationsTable"; +import { SpanToDatasetExampleDialog } from "./SpanToDatasetExampleDialog"; + +/** + * A span attribute object that is a map of string to an unknown value + */ +type AttributeObject = { + [SemanticAttributePrefixes.retrieval]?: AttributeRetrieval; + [SemanticAttributePrefixes.embedding]?: AttributeEmbedding; + [SemanticAttributePrefixes.tool]?: AttributeTool; + [SemanticAttributePrefixes.reranker]?: AttributeReranker; + [SemanticAttributePrefixes.llm]?: AttributeLlm; +}; + +type Span = NonNullable< + TraceDetailsQuery$data["project"]["trace"] +>["spans"]["edges"][number]["span"]; + +type DocumentEvaluation = Span["documentEvaluations"][number]; + +/** + * Hook that safely parses a JSON string. + */ +const useSafelyParsedJSON = ( + jsonStr: string +): { json: { [key: string]: unknown } | null; parseError?: unknown } => { + return useMemo(() => { + try { + return { json: JSON.parse(jsonStr) }; + } catch (e) { + return { json: null, parseError: e }; + } + }, [jsonStr]); +}; + +const spanHasException = (span: Span) => { + return span.events.some((event) => event.name === "exception"); +}; + +/** + * Card props to apply across all cards + */ +const defaultCardProps: Partial = { + backgroundColor: "light", + borderColor: "light", + variant: "compact", + collapsible: true, +}; + +export function SpanDetails({ + selectedSpan, + projectId, +}: { + selectedSpan: Span; + projectId: string; +}) { + const hasExceptions = useMemo(() => { + return spanHasException(selectedSpan); + }, [selectedSpan]); + const showAnnotations = useFeatureFlag("annotations"); + return ( + + + + + + + + {showAnnotations ? ( + + ) : null} + + + + + + + + + {selectedSpan.spanEvaluations.length} + + } + > + {(selected) => { + return selected ? : null; + }} + + + + } + bodyStyle={{ padding: 0 }} + > + {selectedSpan.attributes} + + + + + {selectedSpan.events.length} + + } + > + + + + + ); +} + +function AddSpanToDatasetButton({ span }: { span: Span }) { + const [dialog, setDialog] = useState(null); + const notifySuccess = useNotifySuccess(); + const navigate = useNavigate(); + const onAddSpanToDataset = useCallback(() => { + setDialog( + { + setDialog(null); + notifySuccess({ + title: "Span Added to Dataset", + message: "Successfully added span to dataset", + action: { + text: "View Dataset", + onClick: () => { + navigate(`/datasets/${datasetId}/examples`); + }, + }, + }); + }} + /> + ); + }, [span.id, notifySuccess, navigate]); + return ( + <> + + + setDialog(null)} + > + {dialog} + + + + ); +} + +function SpanInfo({ span }: { span: Span }) { + const { spanKind, attributes } = span; + // Parse the attributes once + const { json: attributesObject, parseError } = + useSafelyParsedJSON(attributes); + + const statusDescription = useMemo(() => { + return span.statusMessage ? ( + + {span.statusMessage} + + ) : null; + }, [span]); + + // Handle the case where the attributes are not a valid JSON object + if (parseError || !attributesObject) { + return ( + + + {statusDescription} + + {`Failed to parse span attributes. ${parseError instanceof Error ? parseError.message : ""}`} + + + {attributes} + + + + ); + } + + let content: ReactNode; + switch (spanKind) { + case "llm": { + content = ; + break; + } + case "retriever": { + content = ( + + ); + break; + } + case "reranker": { + content = ( + + ); + break; + } + case "embedding": { + content = ( + + ); + break; + } + case "tool": { + content = ; + break; + } + default: + content = ; + } + + return ( + + + {statusDescription} + {content} + {attributesObject?.metadata ? ( + + {JSON.stringify(attributesObject.metadata)} + + ) : null} + + + ); +} + +function LLMSpanInfo(props: { span: Span; spanAttributes: AttributeObject }) { + const { spanAttributes, span } = props; + const { input, output } = span; + const llmAttributes = useMemo( + () => spanAttributes[SemanticAttributePrefixes.llm] || null, + [spanAttributes] + ); + + const modelName = useMemo(() => { + if (llmAttributes == null) { + return null; + } + const maybeModelName = llmAttributes[LLMAttributePostfixes.model_name]; + if (typeof maybeModelName === "string") { + return maybeModelName; + } + return null; + }, [llmAttributes]); + + const inputMessages = useMemo(() => { + if (llmAttributes == null) { + return []; + } + return (llmAttributes[LLMAttributePostfixes.input_messages] + ?.map((obj) => obj[SemanticAttributePrefixes.message]) + .filter(Boolean) || []) as AttributeMessage[]; + }, [llmAttributes]); + + const outputMessages = useMemo(() => { + if (llmAttributes == null) { + return []; + } + return (llmAttributes[LLMAttributePostfixes.output_messages] + ?.map((obj) => obj[SemanticAttributePrefixes.message]) + .filter(Boolean) || []) as AttributeMessage[]; + }, [llmAttributes]); + + const prompts = useMemo(() => { + if (llmAttributes == null) { + return []; + } + const maybePrompts = llmAttributes[LLMAttributePostfixes.prompts]; + if (!isStringArray(maybePrompts)) { + return []; + } + return maybePrompts; + }, [llmAttributes]); + + const promptTemplateObject = useMemo(() => { + if (llmAttributes == null) { + return null; + } + const maybePromptTemplate = + llmAttributes[LLMAttributePostfixes.prompt_template]; + if (maybePromptTemplate == null) { + return null; + } + return maybePromptTemplate; + }, [llmAttributes]); + + const invocation_parameters_str = useMemo(() => { + if (llmAttributes == null) { + return "{}"; + } + return (llmAttributes[LLMAttributePostfixes.invocation_parameters] || + "{}") as string; + }, [llmAttributes]); + + const modelNameTitleEl = useMemo(() => { + if (modelName == null) { + return null; + } + return ( + + + + {modelName} + + + ); + }, [modelName]); + const hasInput = input != null && input.value != null; + const hasInputMessages = inputMessages.length > 0; + const hasOutput = output != null && output.value != null; + const hasOutputMessages = outputMessages.length > 0; + const hasPrompts = prompts.length > 0; + const hasInvocationParams = + Object.keys(JSON.parse(invocation_parameters_str)).length > 0; + const hasPromptTemplateObject = promptTemplateObject != null; + + return ( + + + + {hasInputMessages ? ( + + ) : null} + {hasInput ? ( + + } + > + + + + + + ) : null} + {hasPromptTemplateObject ? ( + + ) : null} + + + + + {hasOutput || hasOutputMessages ? ( + + + {hasOutputMessages ? ( + + ) : null} + {hasOutput ? ( + + ) : null} + + + ) : null} + + ); +} + +function RetrieverSpanInfo(props: { + span: Span; + spanAttributes: AttributeObject; +}) { + const { spanAttributes, span } = props; + const { input } = span; + const retrieverAttributes = useMemo( + () => spanAttributes[SemanticAttributePrefixes.retrieval] || null, + [spanAttributes] + ); + const documents = useMemo(() => { + if (retrieverAttributes == null) { + return []; + } + return (retrieverAttributes[RetrievalAttributePostfixes.documents] + ?.map((obj) => obj[SemanticAttributePrefixes.document]) + .filter(Boolean) || []) as AttributeDocument[]; + }, [retrieverAttributes]); + + // Construct a map of document position to document evaluations + const documentEvaluationsMap = useMemo< + Record + >(() => { + const documentEvaluations = span.documentEvaluations; + return documentEvaluations.reduce( + (acc, documentEvaluation) => { + const documentPosition = documentEvaluation.documentPosition; + const evaluations = acc[documentPosition] || []; + return { + ...acc, + [documentPosition]: [...evaluations, documentEvaluation], + }; + }, + {} as Record + ); + }, [span.documentEvaluations]); + + const hasInput = input != null && input.value != null; + const hasDocuments = documents.length > 0; + const hasDocumentRetrievalMetrics = span.documentRetrievalMetrics.length > 0; + return ( + + {hasInput ? ( + + + + + + } + > + + + + ) : null} + {hasDocuments ? ( + + + {span.documentRetrievalMetrics.map((retrievalMetric) => { + return ( + <> + + + + + ); + })} + + ) + } + extra={} + > +
    + {documents.map((document, idx) => { + return ( +
  • + +
  • + ); + })} +
+
+
+ ) : null} + + ); +} + +function RerankerSpanInfo(props: { + span: Span; + spanAttributes: AttributeObject; +}) { + const { spanAttributes } = props; + const rerankerAttributes = useMemo( + () => spanAttributes[SemanticAttributePrefixes.reranker] || null, + [spanAttributes] + ); + const query = useMemo(() => { + if (rerankerAttributes == null) { + return ""; + } + return (rerankerAttributes[RerankerAttributePostfixes.query] || + "") as string; + }, [rerankerAttributes]); + const input_documents = useMemo(() => { + if (rerankerAttributes == null) { + return []; + } + return (rerankerAttributes[RerankerAttributePostfixes.input_documents] + ?.map((obj) => obj[SemanticAttributePrefixes.document]) + .filter(Boolean) || []) as AttributeDocument[]; + }, [rerankerAttributes]); + const output_documents = useMemo(() => { + if (rerankerAttributes == null) { + return []; + } + return (rerankerAttributes[RerankerAttributePostfixes.output_documents] + ?.map((obj) => obj[SemanticAttributePrefixes.document]) + .filter(Boolean) || []) as AttributeDocument[]; + }, [rerankerAttributes]); + + const numInputDocuments = input_documents.length; + const numOutputDocuments = output_documents.length; + return ( + + + + {query} + + + {numInputDocuments}} + {...defaultCardProps} + defaultOpen={false} + > + { +
    + {input_documents.map((document, idx) => { + return ( +
  • + +
  • + ); + })} +
+ } +
+ {numOutputDocuments}} + {...defaultCardProps} + > + { +
    + {output_documents.map((document, idx) => { + return ( +
  • + +
  • + ); + })} +
+ } +
+
+ ); +} + +function EmbeddingSpanInfo(props: { + span: Span; + spanAttributes: AttributeObject; +}) { + const { spanAttributes } = props; + const embeddingAttributes = useMemo( + () => spanAttributes[SemanticAttributePrefixes.embedding] || null, + [spanAttributes] + ); + const embeddings = useMemo(() => { + if (embeddingAttributes == null) { + return []; + } + return (embeddingAttributes[EmbeddingAttributePostfixes.embeddings] + ?.map((obj) => obj[SemanticAttributePrefixes.embedding]) + .filter(Boolean) || []) as AttributeEmbeddingEmbedding[]; + }, [embeddingAttributes]); + + const hasEmbeddings = embeddings.length > 0; + const modelName = + embeddingAttributes?.[EmbeddingAttributePostfixes.model_name]; + return ( + + {hasEmbeddings ? ( + + { +
    + {embeddings.map((embedding, idx) => { + return ( +
  • + + + + {embedding[EmbeddingAttributePostfixes.text] || ""} + + + +
  • + ); + })} +
+ } +
+ ) : null} +
+ ); +} + +function ToolSpanInfo(props: { span: Span; spanAttributes: AttributeObject }) { + const { span, spanAttributes } = props; + const { input, output } = span; + const hasInput = typeof input?.value === "string"; + const hasOutput = typeof output?.value === "string"; + const inputIsText = input?.mimeType === "text"; + const outputIsText = output?.mimeType === "text"; + const toolAttributes = useMemo( + () => spanAttributes[SemanticAttributePrefixes.tool] || {}, + [spanAttributes] + ); + const hasToolAttributes = Object.keys(toolAttributes).length > 0; + const toolName = toolAttributes[ToolAttributePostfixes.name]; + const toolDescription = toolAttributes[ToolAttributePostfixes.description]; + const toolParameters = toolAttributes[ToolAttributePostfixes.parameters]; + if (!hasInput && !hasOutput && !hasToolAttributes) { + return null; + } + return ( + + {hasInput ? ( + + + {inputIsText ? : null} + + + } + > + + + + ) : null} + {hasOutput ? ( + + + {outputIsText ? : null} + + + } + > + + + + ) : null} + {hasToolAttributes ? ( + + + {toolDescription != null ? ( + + + + Description + + {toolDescription as string} + + + ) : null} + {toolParameters != null ? ( + + + + Parameters + + + {JSON.stringify(toolParameters) as string} + + + + ) : null} + + + ) : null} + + ); +} + +// Labels that get highlighted as danger in the document evaluations +const DANGER_DOCUMENT_EVALUATION_LABELS = ["irrelevant", "unrelated"]; +function DocumentItem({ + document, + documentEvaluations, + backgroundColor, + borderColor, + labelColor, +}: { + document: AttributeDocument; + documentEvaluations?: DocumentEvaluation[] | null; + backgroundColor: ViewProps["backgroundColor"]; + borderColor: ViewProps["borderColor"]; + labelColor: LabelProps["color"]; +}) { + const metadata = document[DocumentAttributePostfixes.metadata]; + const hasEvaluations = documentEvaluations && documentEvaluations.length; + const documentContent = document[DocumentAttributePostfixes.content]; + return ( + + } /> + + document {document[DocumentAttributePostfixes.id]} + + + } + extra={ + typeof document[DocumentAttributePostfixes.score] === "number" && ( + + ) + } + > + + {documentContent && ( + + {documentContent} + + )} + {metadata && ( + <> + + {JSON.stringify(metadata)} + + + )} + {hasEvaluations && ( + + + + Evaluations + +
    + {documentEvaluations.map((documentEvaluation, idx) => { + // Highlight the label as danger if it is a danger classification + const evalLabelColor = + documentEvaluation.label && + DANGER_DOCUMENT_EVALUATION_LABELS.includes( + documentEvaluation.label + ) + ? "danger" + : labelColor; + return ( +
  • + + + + + {documentEvaluation.name} + + {documentEvaluation.label && ( + + )} + {typeof documentEvaluation.score === "number" && ( + + )} + + {typeof documentEvaluation.explanation && ( +

    + {documentEvaluation.explanation} +

    + )} +
    +
    +
  • + ); + })} +
+
+
+ )} +
+
+ ); +} + +function LLMMessage({ message }: { message: AttributeMessage }) { + const messageContent = message[MessageAttributePostfixes.content]; + // as of multi-modal models, a message can also be a list + const messagesContents = message[MessageAttributePostfixes.contents]; + const toolCalls = + message[MessageAttributePostfixes.tool_calls] + ?.map((obj) => obj[SemanticAttributePrefixes.tool_call]) + .filter(Boolean) || []; + const hasFunctionCall = + message[MessageAttributePostfixes.function_call_arguments_json] && + message[MessageAttributePostfixes.function_call_name]; + const role = message[MessageAttributePostfixes.role] || "unknown"; + const messageStyles = useMemo(() => { + if (role === "user") { + return { + backgroundColor: "grey-100", + borderColor: "grey-500", + }; + } else if (role === "assistant") { + return { + backgroundColor: "blue-100", + borderColor: "blue-700", + }; + } else if (role === "system") { + return { + backgroundColor: "indigo-100", + borderColor: "indigo-700", + }; + } else if (["function", "tool"].includes(role)) { + return { + backgroundColor: "yellow-100", + borderColor: "yellow-700", + }; + } + return { + backgroundColor: "grey-100", + borderColor: "grey-700", + }; + }, [role]); + + return ( + + + + + + } + > + + {messagesContents ? ( + + ) : null} + + + {messageContent ? ( + {messageContent} + ) : null} + {toolCalls.length > 0 + ? toolCalls.map((toolCall, idx) => { + return ( +
+                    {toolCall?.function?.name as string}(
+                    {JSON.stringify(
+                      JSON.parse(toolCall?.function?.arguments as string),
+                      null,
+                      2
+                    )}
+                    )
+                  
+ ); + }) + : null} + {/*functionCall is deprecated and is superseded by toolCalls, so we don't expect both to be present*/} + {hasFunctionCall ? ( +
+              {message[MessageAttributePostfixes.function_call_name] as string}(
+              {JSON.stringify(
+                JSON.parse(
+                  message[
+                    MessageAttributePostfixes.function_call_arguments_json
+                  ] as string
+                ),
+                null,
+                2
+              )}
+              )
+            
+ ) : null} +
+
+
+ ); +} +function LLMMessagesList({ messages }: { messages: AttributeMessage[] }) { + return ( +
    + {messages.map((message, idx) => { + return ( +
  • + +
  • + ); + })} +
+ ); +} + +function LLMPromptsList({ prompts }: { prompts: string[] }) { + return ( +
    + {prompts.map((prompt, idx) => { + return ( +
  • + + + + + +
  • + ); + })} +
+ ); +} + +/** + * A list of message contents. Used for multi-modal models. + */ +function MessageContentsList({ + messageContents, +}: { + messageContents: AttributeMessageContent[]; +}) { + return ( +
    + {messageContents.map((messageContent, idx) => { + return ( +
  • + +
  • + ); + })} +
+ ); +} + +const imageCSS = css` + max-width: 100%; + max-height: 100%; + object-fit: cover; +`; + +/** + * Displays multi-modal message content. Typically an image or text. + * Examples: + * {"message_content":{"text":"What is in this image?","type":"text"}} + * {"message_content":{"type":"image","image":{"image":{"url":"https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg"}}}} + */ +function MessageContent({ + messageContentAttribute, +}: { + messageContentAttribute: AttributeMessageContent; +}) { + const { message_content } = messageContentAttribute; + const text = message_content?.text; + const image = message_content?.image; + const imageUrl = image?.image?.url; + + return ( + + {text ? ( +
+          {text}
+        
+ ) : null} + {imageUrl ? : null} +
+ ); +} + +function SpanIO({ span }: { span: Span }) { + const { input, output } = span; + const isMissingIO = input == null && output == null; + const inputIsText = input?.mimeType === "text"; + const outputIsText = output?.mimeType === "text"; + return ( + + {input && input.value != null ? ( + + + {inputIsText ? : null} + + + } + > + + + + ) : null} + {output && output.value != null ? ( + + + {outputIsText ? : null} + + + } + > + + + + ) : null} + {isMissingIO ? ( + } + > + {span.attributes} + + ) : null} + + ); +} + +const codeMirrorCSS = css` + .cm-content { + padding: var(--ac-global-dimension-static-size-200) 0; + } + .cm-editor, + .cm-gutters { + background-color: transparent; + } +`; + +function CopyToClipboard({ + text, + children, + padding, +}: PropsWithChildren<{ text: string; padding?: "size-100" }>) { + const paddingValue = padding ? `var(--ac-global-dimension-${padding})` : "0"; + return ( +
+ + {children} +
+ ); +} +/** + * A block of JSON content that is not editable. + */ +function JSONBlock({ children }: { children: string }) { + const { theme } = useTheme(); + const codeMirrorTheme = theme === "light" ? undefined : nord; + // We need to make sure that the content can actually be displayed + // As JSON as we cannot fully trust the backend to always send valid JSON + const { value, mimeType } = useMemo(() => { + try { + // Attempt to pretty print the JSON. This may fail if the JSON is invalid. + // E.g. sometimes it contains NANs due to poor JSON.dumps in the backend + return { + value: JSON.stringify(JSON.parse(children), null, 2), + mimeType: "json" as const, + }; + } catch (e) { + // Fall back to string + return { value: children, mimeType: "text" as const }; + } + }, [children]); + if (mimeType === "json") { + return ( + + ); + } else { + return {value}; + } +} + +function PreBlock({ children }: { children: string }) { + return ( +
+      {children}
+    
+ ); +} + +function CodeBlock({ value, mimeType }: { value: string; mimeType: MimeType }) { + let content; + switch (mimeType) { + case "json": + content = {value}; + break; + case "text": + content = {value}; + break; + default: + assertUnreachable(mimeType); + } + return content; +} + +function EmptyIndicator({ text }: { text: string }) { + return ( + + + {text} + + ); +} +function SpanEventsList({ events }: { events: Span["events"] }) { + if (events.length === 0) { + return ; + } + return ( + + {events.map((event, idx) => { + const isException = event.name === "exception"; + + return ( + + + +
css` + &[data-event-type="exception"] { + --px-event-icon-color: ${theme.colors.statusDanger}; + } + &[data-event-type="info"] { + --px-event-icon-color: ${theme.colors.statusInfo}; + } + .ac-icon-wrap { + color: var(--px-event-icon-color); + } + `} + > + + ) : ( + + ) + } + /> +
+
+ + {event.name} + {event.message} + + + + {new Date(event.timestamp).toLocaleString()} + + +
+
+ ); + })} +
+ ); +} + +function SpanEvaluations(props: { span: Span }) { + return ; +} + +const attributesContextualHelp = ( + + + + + Span Attributes + + + + Attributes are key-value pairs that represent metadata associated + with a span. For detailed descriptions of specific attributes, + consult the semantic conventions section of the OpenInference + tracing specification. + + +
+ + Semantic Conventions + +
+
+
+
+); diff --git a/app/src/pages/trace/TraceDetails.tsx b/app/src/pages/trace/TraceDetails.tsx index c2abfa8425..ed3a5fc03c 100644 --- a/app/src/pages/trace/TraceDetails.tsx +++ b/app/src/pages/trace/TraceDetails.tsx @@ -1,147 +1,28 @@ -import React, { - PropsWithChildren, - ReactNode, - Suspense, - useCallback, - useEffect, - useMemo, - useState, -} from "react"; +import React, { PropsWithChildren, useEffect, useMemo } from "react"; import { graphql, useLazyLoadQuery } from "react-relay"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; -import { useNavigate, useSearchParams } from "react-router-dom"; -import { json } from "@codemirror/lang-json"; -import { EditorView } from "@codemirror/view"; -import { nord } from "@uiw/codemirror-theme-nord"; -import CodeMirror from "@uiw/react-codemirror"; +import { useSearchParams } from "react-router-dom"; import { css } from "@emotion/react"; -import { - Alert, - Button, - Card, - CardProps, - Content, - ContextualHelp, - Counter, - DialogContainer, - EmptyGraphic, - Flex, - Heading, - Icon, - Icons, - Label, - LabelProps, - List, - ListItem, - TabbedCard, - TabPane, - Tabs, - Text, - View, - ViewProps, - ViewStyleProps, -} from "@arizeai/components"; -import { - EmbeddingAttributePostfixes, - LLMAttributePostfixes, - MessageAttributePostfixes, - RerankerAttributePostfixes, - RetrievalAttributePostfixes, - ToolAttributePostfixes, -} from "@arizeai/openinference-semantic-conventions"; -import { - DocumentAttributePostfixes, - SemanticAttributePrefixes, -} from "@arizeai/openinference-semantic-conventions/src/trace/SemanticConventions"; +import { Flex, Text, View } from "@arizeai/components"; -import { CopyToClipboardButton, ExternalLink } from "@phoenix/components"; -import { ErrorBoundary } from "@phoenix/components/ErrorBoundary"; -import { - ConnectedMarkdownBlock, - MarkdownDisplayProvider, -} from "@phoenix/components/markdown"; -import { ConnectedMarkdownModeRadioGroup } from "@phoenix/components/markdown/MarkdownModeRadioGroup"; import { resizeHandleCSS } from "@phoenix/components/resize"; import { LatencyText } from "@phoenix/components/trace/LatencyText"; -import { SpanItem } from "@phoenix/components/trace/SpanItem"; -import { SpanKindIcon } from "@phoenix/components/trace/SpanKindIcon"; import { SpanStatusCodeIcon } from "@phoenix/components/trace/SpanStatusCodeIcon"; import { TraceTree } from "@phoenix/components/trace/TraceTree"; import { useSpanStatusCodeColor } from "@phoenix/components/trace/useSpanStatusCodeColor"; -import { useNotifySuccess, useTheme } from "@phoenix/contexts"; -import { useFeatureFlag } from "@phoenix/contexts/FeatureFlagsContext"; -import { - AttributeDocument, - AttributeEmbedding, - AttributeEmbeddingEmbedding, - AttributeLlm, - AttributeMessage, - AttributeMessageContent, - AttributePromptTemplate, - AttributeReranker, - AttributeRetrieval, - AttributeTool, -} from "@phoenix/openInference/tracing/types"; -import { assertUnreachable, isStringArray } from "@phoenix/typeUtils"; -import { formatFloat, numberFormatter } from "@phoenix/utils/numberFormatUtils"; import { EvaluationLabel } from "../project/EvaluationLabel"; -import { RetrievalEvaluationLabel } from "../project/RetrievalEvaluationLabel"; import { - MimeType, TraceDetailsQuery, TraceDetailsQuery$data, } from "./__generated__/TraceDetailsQuery.graphql"; -import { EditSpanAnnotationsButton } from "./EditSpanAnnotationsButton"; -import { SpanCodeDropdown } from "./SpanCodeDropdown"; -import { SpanEvaluationsTable } from "./SpanEvaluationsTable"; -import { SpanToDatasetExampleDialog } from "./SpanToDatasetExampleDialog"; +import { SpanDetails } from "./SpanDetails"; type Span = NonNullable< TraceDetailsQuery$data["project"]["trace"] >["spans"]["edges"][number]["span"]; -type DocumentEvaluation = Span["documentEvaluations"][number]; -/** - * A span attribute object that is a map of string to an unknown value - */ -type AttributeObject = { - [SemanticAttributePrefixes.retrieval]?: AttributeRetrieval; - [SemanticAttributePrefixes.embedding]?: AttributeEmbedding; - [SemanticAttributePrefixes.tool]?: AttributeTool; - [SemanticAttributePrefixes.reranker]?: AttributeReranker; - [SemanticAttributePrefixes.llm]?: AttributeLlm; -}; - -/** - * Hook that safely parses a JSON string. - */ -const useSafelyParsedJSON = ( - jsonStr: string -): { json: { [key: string]: unknown } | null; parseError?: unknown } => { - return useMemo(() => { - try { - return { json: JSON.parse(jsonStr) }; - } catch (e) { - return { json: null, parseError: e }; - } - }, [jsonStr]); -}; - -const spanHasException = (span: Span) => { - return span.events.some((event) => event.name === "exception"); -}; - -/** - * Card props to apply across all cards - */ -const defaultCardProps: Partial = { - backgroundColor: "light", - borderColor: "light", - variant: "compact", - collapsible: true, -}; /** * A root span is defined to be a span whose parent span is not in our collection. @@ -305,10 +186,7 @@ export function TraceDetails(props: TraceDetailsProps) { {selectedSpan ? ( - + ) : null} @@ -410,1466 +288,3 @@ function ScrollingPanelContent({ children }: PropsWithChildren) { ); } - -const attributesContextualHelp = ( - - - - - Span Attributes - - - - Attributes are key-value pairs that represent metadata associated - with a span. For detailed descriptions of specific attributes, - consult the semantic conventions section of the OpenInference - tracing specification. - - -
- - Semantic Conventions - -
-
-
-
-); - -function SelectedSpanDetails({ - selectedSpan, - projectId, -}: { - selectedSpan: Span; - projectId: string; -}) { - const hasExceptions = useMemo(() => { - return spanHasException(selectedSpan); - }, [selectedSpan]); - const showAnnotations = useFeatureFlag("annotations"); - return ( - - - - - - - - {showAnnotations ? ( - - ) : null} - - - - - - - - - {selectedSpan.spanEvaluations.length} - - } - > - {(selected) => { - return selected ? : null; - }} - - - - } - bodyStyle={{ padding: 0 }} - > - {selectedSpan.attributes} - - - - - {selectedSpan.events.length} - - } - > - - - - - ); -} - -function AddSpanToDatasetButton({ span }: { span: Span }) { - const [dialog, setDialog] = useState(null); - const notifySuccess = useNotifySuccess(); - const navigate = useNavigate(); - const onAddSpanToDataset = useCallback(() => { - setDialog( - { - setDialog(null); - notifySuccess({ - title: "Span Added to Dataset", - message: "Successfully added span to dataset", - action: { - text: "View Dataset", - onClick: () => { - navigate(`/datasets/${datasetId}/examples`); - }, - }, - }); - }} - /> - ); - }, [span.id, notifySuccess, navigate]); - return ( - <> - - - setDialog(null)} - > - {dialog} - - - - ); -} - -function SpanInfo({ span }: { span: Span }) { - const { spanKind, attributes } = span; - // Parse the attributes once - const { json: attributesObject, parseError } = - useSafelyParsedJSON(attributes); - - const statusDescription = useMemo(() => { - return span.statusMessage ? ( - - {span.statusMessage} - - ) : null; - }, [span]); - - // Handle the case where the attributes are not a valid JSON object - if (parseError || !attributesObject) { - return ( - - - {statusDescription} - - {`Failed to parse span attributes. ${parseError instanceof Error ? parseError.message : ""}`} - - - {attributes} - - - - ); - } - - let content: ReactNode; - switch (spanKind) { - case "llm": { - content = ; - break; - } - case "retriever": { - content = ( - - ); - break; - } - case "reranker": { - content = ( - - ); - break; - } - case "embedding": { - content = ( - - ); - break; - } - case "tool": { - content = ; - break; - } - default: - content = ; - } - - return ( - - - {statusDescription} - {content} - {attributesObject?.metadata ? ( - - {JSON.stringify(attributesObject.metadata)} - - ) : null} - - - ); -} - -function LLMSpanInfo(props: { span: Span; spanAttributes: AttributeObject }) { - const { spanAttributes, span } = props; - const { input, output } = span; - const llmAttributes = useMemo( - () => spanAttributes[SemanticAttributePrefixes.llm] || null, - [spanAttributes] - ); - - const modelName = useMemo(() => { - if (llmAttributes == null) { - return null; - } - const maybeModelName = llmAttributes[LLMAttributePostfixes.model_name]; - if (typeof maybeModelName === "string") { - return maybeModelName; - } - return null; - }, [llmAttributes]); - - const inputMessages = useMemo(() => { - if (llmAttributes == null) { - return []; - } - return (llmAttributes[LLMAttributePostfixes.input_messages] - ?.map((obj) => obj[SemanticAttributePrefixes.message]) - .filter(Boolean) || []) as AttributeMessage[]; - }, [llmAttributes]); - - const outputMessages = useMemo(() => { - if (llmAttributes == null) { - return []; - } - return (llmAttributes[LLMAttributePostfixes.output_messages] - ?.map((obj) => obj[SemanticAttributePrefixes.message]) - .filter(Boolean) || []) as AttributeMessage[]; - }, [llmAttributes]); - - const prompts = useMemo(() => { - if (llmAttributes == null) { - return []; - } - const maybePrompts = llmAttributes[LLMAttributePostfixes.prompts]; - if (!isStringArray(maybePrompts)) { - return []; - } - return maybePrompts; - }, [llmAttributes]); - - const promptTemplateObject = useMemo(() => { - if (llmAttributes == null) { - return null; - } - const maybePromptTemplate = - llmAttributes[LLMAttributePostfixes.prompt_template]; - if (maybePromptTemplate == null) { - return null; - } - return maybePromptTemplate; - }, [llmAttributes]); - - const invocation_parameters_str = useMemo(() => { - if (llmAttributes == null) { - return "{}"; - } - return (llmAttributes[LLMAttributePostfixes.invocation_parameters] || - "{}") as string; - }, [llmAttributes]); - - const modelNameTitleEl = useMemo(() => { - if (modelName == null) { - return null; - } - return ( - - - - {modelName} - - - ); - }, [modelName]); - const hasInput = input != null && input.value != null; - const hasInputMessages = inputMessages.length > 0; - const hasOutput = output != null && output.value != null; - const hasOutputMessages = outputMessages.length > 0; - const hasPrompts = prompts.length > 0; - const hasInvocationParams = - Object.keys(JSON.parse(invocation_parameters_str)).length > 0; - const hasPromptTemplateObject = promptTemplateObject != null; - - return ( - - - - {hasInputMessages ? ( - - ) : null} - {hasInput ? ( - - } - > - - - - - - ) : null} - {hasPromptTemplateObject ? ( - - ) : null} - - - - - {hasOutput || hasOutputMessages ? ( - - - {hasOutputMessages ? ( - - ) : null} - {hasOutput ? ( - - ) : null} - - - ) : null} - - ); -} - -function RetrieverSpanInfo(props: { - span: Span; - spanAttributes: AttributeObject; -}) { - const { spanAttributes, span } = props; - const { input } = span; - const retrieverAttributes = useMemo( - () => spanAttributes[SemanticAttributePrefixes.retrieval] || null, - [spanAttributes] - ); - const documents = useMemo(() => { - if (retrieverAttributes == null) { - return []; - } - return (retrieverAttributes[RetrievalAttributePostfixes.documents] - ?.map((obj) => obj[SemanticAttributePrefixes.document]) - .filter(Boolean) || []) as AttributeDocument[]; - }, [retrieverAttributes]); - - // Construct a map of document position to document evaluations - const documentEvaluationsMap = useMemo< - Record - >(() => { - const documentEvaluations = span.documentEvaluations; - return documentEvaluations.reduce( - (acc, documentEvaluation) => { - const documentPosition = documentEvaluation.documentPosition; - const evaluations = acc[documentPosition] || []; - return { - ...acc, - [documentPosition]: [...evaluations, documentEvaluation], - }; - }, - {} as Record - ); - }, [span.documentEvaluations]); - - const hasInput = input != null && input.value != null; - const hasDocuments = documents.length > 0; - const hasDocumentRetrievalMetrics = span.documentRetrievalMetrics.length > 0; - return ( - - {hasInput ? ( - - - - - - } - > - - - - ) : null} - {hasDocuments ? ( - - - {span.documentRetrievalMetrics.map((retrievalMetric) => { - return ( - <> - - - - - ); - })} - - ) - } - extra={} - > -
    - {documents.map((document, idx) => { - return ( -
  • - -
  • - ); - })} -
-
-
- ) : null} - - ); -} - -function RerankerSpanInfo(props: { - span: Span; - spanAttributes: AttributeObject; -}) { - const { spanAttributes } = props; - const rerankerAttributes = useMemo( - () => spanAttributes[SemanticAttributePrefixes.reranker] || null, - [spanAttributes] - ); - const query = useMemo(() => { - if (rerankerAttributes == null) { - return ""; - } - return (rerankerAttributes[RerankerAttributePostfixes.query] || - "") as string; - }, [rerankerAttributes]); - const input_documents = useMemo(() => { - if (rerankerAttributes == null) { - return []; - } - return (rerankerAttributes[RerankerAttributePostfixes.input_documents] - ?.map((obj) => obj[SemanticAttributePrefixes.document]) - .filter(Boolean) || []) as AttributeDocument[]; - }, [rerankerAttributes]); - const output_documents = useMemo(() => { - if (rerankerAttributes == null) { - return []; - } - return (rerankerAttributes[RerankerAttributePostfixes.output_documents] - ?.map((obj) => obj[SemanticAttributePrefixes.document]) - .filter(Boolean) || []) as AttributeDocument[]; - }, [rerankerAttributes]); - - const numInputDocuments = input_documents.length; - const numOutputDocuments = output_documents.length; - return ( - - - - {query} - - - {numInputDocuments}} - {...defaultCardProps} - defaultOpen={false} - > - { -
    - {input_documents.map((document, idx) => { - return ( -
  • - -
  • - ); - })} -
- } -
- {numOutputDocuments}} - {...defaultCardProps} - > - { -
    - {output_documents.map((document, idx) => { - return ( -
  • - -
  • - ); - })} -
- } -
-
- ); -} - -function EmbeddingSpanInfo(props: { - span: Span; - spanAttributes: AttributeObject; -}) { - const { spanAttributes } = props; - const embeddingAttributes = useMemo( - () => spanAttributes[SemanticAttributePrefixes.embedding] || null, - [spanAttributes] - ); - const embeddings = useMemo(() => { - if (embeddingAttributes == null) { - return []; - } - return (embeddingAttributes[EmbeddingAttributePostfixes.embeddings] - ?.map((obj) => obj[SemanticAttributePrefixes.embedding]) - .filter(Boolean) || []) as AttributeEmbeddingEmbedding[]; - }, [embeddingAttributes]); - - const hasEmbeddings = embeddings.length > 0; - const modelName = - embeddingAttributes?.[EmbeddingAttributePostfixes.model_name]; - return ( - - {hasEmbeddings ? ( - - { -
    - {embeddings.map((embedding, idx) => { - return ( -
  • - - - - {embedding[EmbeddingAttributePostfixes.text] || ""} - - - -
  • - ); - })} -
- } -
- ) : null} -
- ); -} - -function ToolSpanInfo(props: { span: Span; spanAttributes: AttributeObject }) { - const { span, spanAttributes } = props; - const { input, output } = span; - const hasInput = typeof input?.value === "string"; - const hasOutput = typeof output?.value === "string"; - const inputIsText = input?.mimeType === "text"; - const outputIsText = output?.mimeType === "text"; - const toolAttributes = useMemo( - () => spanAttributes[SemanticAttributePrefixes.tool] || {}, - [spanAttributes] - ); - const hasToolAttributes = Object.keys(toolAttributes).length > 0; - const toolName = toolAttributes[ToolAttributePostfixes.name]; - const toolDescription = toolAttributes[ToolAttributePostfixes.description]; - const toolParameters = toolAttributes[ToolAttributePostfixes.parameters]; - if (!hasInput && !hasOutput && !hasToolAttributes) { - return null; - } - return ( - - {hasInput ? ( - - - {inputIsText ? : null} - - - } - > - - - - ) : null} - {hasOutput ? ( - - - {outputIsText ? : null} - - - } - > - - - - ) : null} - {hasToolAttributes ? ( - - - {toolDescription != null ? ( - - - - Description - - {toolDescription as string} - - - ) : null} - {toolParameters != null ? ( - - - - Parameters - - - {JSON.stringify(toolParameters) as string} - - - - ) : null} - - - ) : null} - - ); -} - -// Labels that get highlighted as danger in the document evaluations -const DANGER_DOCUMENT_EVALUATION_LABELS = ["irrelevant", "unrelated"]; -function DocumentItem({ - document, - documentEvaluations, - backgroundColor, - borderColor, - labelColor, -}: { - document: AttributeDocument; - documentEvaluations?: DocumentEvaluation[] | null; - backgroundColor: ViewProps["backgroundColor"]; - borderColor: ViewProps["borderColor"]; - labelColor: LabelProps["color"]; -}) { - const metadata = document[DocumentAttributePostfixes.metadata]; - const hasEvaluations = documentEvaluations && documentEvaluations.length; - const documentContent = document[DocumentAttributePostfixes.content]; - return ( - - } /> - - document {document[DocumentAttributePostfixes.id]} - - - } - extra={ - typeof document[DocumentAttributePostfixes.score] === "number" && ( - - ) - } - > - - {documentContent && ( - - {documentContent} - - )} - {metadata && ( - <> - - {JSON.stringify(metadata)} - - - )} - {hasEvaluations && ( - - - - Evaluations - -
    - {documentEvaluations.map((documentEvaluation, idx) => { - // Highlight the label as danger if it is a danger classification - const evalLabelColor = - documentEvaluation.label && - DANGER_DOCUMENT_EVALUATION_LABELS.includes( - documentEvaluation.label - ) - ? "danger" - : labelColor; - return ( -
  • - - - - - {documentEvaluation.name} - - {documentEvaluation.label && ( - - )} - {typeof documentEvaluation.score === "number" && ( - - )} - - {typeof documentEvaluation.explanation && ( -

    - {documentEvaluation.explanation} -

    - )} -
    -
    -
  • - ); - })} -
-
-
- )} -
-
- ); -} - -function LLMMessage({ message }: { message: AttributeMessage }) { - const messageContent = message[MessageAttributePostfixes.content]; - // as of multi-modal models, a message can also be a list - const messagesContents = message[MessageAttributePostfixes.contents]; - const toolCalls = - message[MessageAttributePostfixes.tool_calls] - ?.map((obj) => obj[SemanticAttributePrefixes.tool_call]) - .filter(Boolean) || []; - const hasFunctionCall = - message[MessageAttributePostfixes.function_call_arguments_json] && - message[MessageAttributePostfixes.function_call_name]; - const role = message[MessageAttributePostfixes.role] || "unknown"; - const messageStyles = useMemo(() => { - if (role === "user") { - return { - backgroundColor: "grey-100", - borderColor: "grey-500", - }; - } else if (role === "assistant") { - return { - backgroundColor: "blue-100", - borderColor: "blue-700", - }; - } else if (role === "system") { - return { - backgroundColor: "indigo-100", - borderColor: "indigo-700", - }; - } else if (["function", "tool"].includes(role)) { - return { - backgroundColor: "yellow-100", - borderColor: "yellow-700", - }; - } - return { - backgroundColor: "grey-100", - borderColor: "grey-700", - }; - }, [role]); - - return ( - - - - - - } - > - - {messagesContents ? ( - - ) : null} - - - {messageContent ? ( - {messageContent} - ) : null} - {toolCalls.length > 0 - ? toolCalls.map((toolCall, idx) => { - return ( -
-                    {toolCall?.function?.name as string}(
-                    {JSON.stringify(
-                      JSON.parse(toolCall?.function?.arguments as string),
-                      null,
-                      2
-                    )}
-                    )
-                  
- ); - }) - : null} - {/*functionCall is deprecated and is superseded by toolCalls, so we don't expect both to be present*/} - {hasFunctionCall ? ( -
-              {message[MessageAttributePostfixes.function_call_name] as string}(
-              {JSON.stringify(
-                JSON.parse(
-                  message[
-                    MessageAttributePostfixes.function_call_arguments_json
-                  ] as string
-                ),
-                null,
-                2
-              )}
-              )
-            
- ) : null} -
-
-
- ); -} -function LLMMessagesList({ messages }: { messages: AttributeMessage[] }) { - return ( -
    - {messages.map((message, idx) => { - return ( -
  • - -
  • - ); - })} -
- ); -} - -function LLMPromptsList({ prompts }: { prompts: string[] }) { - return ( -
    - {prompts.map((prompt, idx) => { - return ( -
  • - - - - - -
  • - ); - })} -
- ); -} - -/** - * A list of message contents. Used for multi-modal models. - */ -function MessageContentsList({ - messageContents, -}: { - messageContents: AttributeMessageContent[]; -}) { - return ( -
    - {messageContents.map((messageContent, idx) => { - return ( -
  • - -
  • - ); - })} -
- ); -} - -const imageCSS = css` - max-width: 100%; - max-height: 100%; - object-fit: cover; -`; - -/** - * Displays multi-modal message content. Typically an image or text. - * Examples: - * {"message_content":{"text":"What is in this image?","type":"text"}} - * {"message_content":{"type":"image","image":{"image":{"url":"https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg"}}}} - */ -function MessageContent({ - messageContentAttribute, -}: { - messageContentAttribute: AttributeMessageContent; -}) { - const { message_content } = messageContentAttribute; - const text = message_content?.text; - const image = message_content?.image; - const imageUrl = image?.image?.url; - - return ( - - {text ? ( -
-          {text}
-        
- ) : null} - {imageUrl ? : null} -
- ); -} - -function SpanIO({ span }: { span: Span }) { - const { input, output } = span; - const isMissingIO = input == null && output == null; - const inputIsText = input?.mimeType === "text"; - const outputIsText = output?.mimeType === "text"; - return ( - - {input && input.value != null ? ( - - - {inputIsText ? : null} - - - } - > - - - - ) : null} - {output && output.value != null ? ( - - - {outputIsText ? : null} - - - } - > - - - - ) : null} - {isMissingIO ? ( - } - > - {span.attributes} - - ) : null} - - ); -} - -const codeMirrorCSS = css` - .cm-content { - padding: var(--ac-global-dimension-static-size-200) 0; - } - .cm-editor, - .cm-gutters { - background-color: transparent; - } -`; - -function CopyToClipboard({ - text, - children, - padding, -}: PropsWithChildren<{ text: string; padding?: "size-100" }>) { - const paddingValue = padding ? `var(--ac-global-dimension-${padding})` : "0"; - return ( -
- - {children} -
- ); -} -/** - * A block of JSON content that is not editable. - */ -function JSONBlock({ children }: { children: string }) { - const { theme } = useTheme(); - const codeMirrorTheme = theme === "light" ? undefined : nord; - // We need to make sure that the content can actually be displayed - // As JSON as we cannot fully trust the backend to always send valid JSON - const { value, mimeType } = useMemo(() => { - try { - // Attempt to pretty print the JSON. This may fail if the JSON is invalid. - // E.g. sometimes it contains NANs due to poor JSON.dumps in the backend - return { - value: JSON.stringify(JSON.parse(children), null, 2), - mimeType: "json" as const, - }; - } catch (e) { - // Fall back to string - return { value: children, mimeType: "text" as const }; - } - }, [children]); - if (mimeType === "json") { - return ( - - ); - } else { - return {value}; - } -} - -function PreBlock({ children }: { children: string }) { - return ( -
-      {children}
-    
- ); -} - -function CodeBlock({ value, mimeType }: { value: string; mimeType: MimeType }) { - let content; - switch (mimeType) { - case "json": - content = {value}; - break; - case "text": - content = {value}; - break; - default: - assertUnreachable(mimeType); - } - return content; -} - -function EmptyIndicator({ text }: { text: string }) { - return ( - - - {text} - - ); -} -function SpanEventsList({ events }: { events: Span["events"] }) { - if (events.length === 0) { - return ; - } - return ( - - {events.map((event, idx) => { - const isException = event.name === "exception"; - - return ( - - - -
css` - &[data-event-type="exception"] { - --px-event-icon-color: ${theme.colors.statusDanger}; - } - &[data-event-type="info"] { - --px-event-icon-color: ${theme.colors.statusInfo}; - } - .ac-icon-wrap { - color: var(--px-event-icon-color); - } - `} - > - - ) : ( - - ) - } - /> -
-
- - {event.name} - {event.message} - - - - {new Date(event.timestamp).toLocaleString()} - - -
-
- ); - })} -
- ); -} - -function SpanEvaluations(props: { span: Span }) { - return ; -}