Compare commits

..

10 Commits

Author SHA1 Message Date
nityanandagohain
ae0d8cb1a4 fix: cleanup 2026-09-29 16:13:20 +05:30
nityanandagohain
abb242b1f9 feat(tracedetail): normalise gen_ai messages in thread spans 2026-09-29 12:47:25 +05:30
nityanandagohain
61f6370f45 fix: remove changes from waterfall 2026-09-29 12:42:30 +05:30
nityanandagohain
f8b22c0feb refactor(tracedetail): split message normalisation out of the thread api 2026-09-29 11:20:56 +05:30
nityanandagohain
9e3a6bb35d Merge remote-tracking branch 'origin/main' into feat/ai-trace-thread 2026-09-29 10:25:57 +05:30
praneeth-signoz
47dd1fabf3 chore(channel-specs): Move channel specs to separate files (#12989)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Moved channel specs to separate files under alert manager types
- Channel receivers are also moved the same file

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes
https://github.com/orgs/SigNoz/projects/34/views/26?pane=issue&itemId=252814150&issue=SigNoz%7Cpulse-pod%7C374
2026-09-28 17:44:35 +00:00
Vikrant Gupta
270988fb48 fix(tokenizer): persist last_observed_at on postgres (#12982)
#### Description

- The flush CTE rendered `last_observed_at` as an untyped literal, which
postgres resolves to `text` and refuses to assign to the `timestamptz`
column. The column never populated, so the idle expiry never applied.
- Build the CTE from the token model with only `id`, `last_observed_at`
and `updated_at`, so bun casts per dialect and no token secrets land in
the statement.
- Flush now applies cached times through `Token.UpdateLastObservedAt`,
which also skips rows with a newer stored value.
- Integration test in `passwordauthn` runs with a short GC interval and
asserts the column populates on both sql stores.
2026-09-28 14:23:01 +00:00
nityanandagohain
f5f019f61b feat: trace detail thread endpoint 2026-09-28 17:32:55 +05:30
Naman Verma
39badeb591 fix: remove rules types package import from migration #049 (#12998)
Some checks failed
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Migration package ideally should have have things from types package
imported. Given that rules v1->v2 will (most probably) update/remove
some of the types, such as removing `PreferredChannels` from
`PostableRule`, better not to have this type imported in migrations
package.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Part of https://github.com/SigNoz/pulse-pod/issues/225
2026-09-28 11:39:07 +00:00
Nityananda Gohain
ec05bfe755 fix: empty patterns in pricing are rejected (#12995)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
Empty patterns were not rejected because of which corrupt config was
created, rejecting them at the handler layer.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/nerve-pod/issues/282
2026-09-28 08:09:38 +00:00
145 changed files with 5329 additions and 2248 deletions

View File

@@ -68,6 +68,7 @@ jobs:
- semconvfamilies
- serviceaccount
- spanmapper
- tracedetail
- querier_json_body
- querier_skip_resource_fingerprint
- ttl

View File

@@ -1,5 +1,44 @@
components:
schemas:
AiobservabilitytypesMessage:
properties:
content:
items:
$ref: '#/components/schemas/AiobservabilitytypesPart'
type: array
finishReason:
type: string
role:
type: string
required:
- content
type: object
AiobservabilitytypesPart:
properties:
arguments: {}
content:
type: string
id:
type: string
isError:
type: boolean
name:
type: string
toolCallId:
type: string
type:
$ref: '#/components/schemas/AiobservabilitytypesPartType'
required:
- type
type: object
AiobservabilitytypesPartType:
enum:
- text
- thinking
- tool_call
- tool_result
- generic
type: string
AlertmanagertypesChannel:
properties:
createdAt:
@@ -9647,6 +9686,17 @@ components:
required:
- aggregations
type: object
SpantypesGettableTraceThread:
properties:
nextCursor:
type: string
spans:
items:
$ref: '#/components/schemas/SpantypesThreadSpan'
type: array
required:
- spans
type: object
SpantypesGettableWaterfallTrace:
properties:
endTimestampMillis:
@@ -9961,6 +10011,92 @@ components:
nullable: true
type: object
type: object
SpantypesThreadSpan:
properties:
attributes:
additionalProperties: {}
nullable: true
type: object
db_name:
type: string
db_operation:
type: string
duration_nano:
minimum: 0
type: integer
events:
items:
$ref: '#/components/schemas/SpantypesEvent'
nullable: true
type: array
external_http_method:
type: string
external_http_url:
type: string
flags:
minimum: 0
type: integer
formatted_input:
items:
$ref: '#/components/schemas/AiobservabilitytypesMessage'
type: array
formatted_output:
items:
$ref: '#/components/schemas/AiobservabilitytypesMessage'
type: array
has_children:
type: boolean
has_error:
type: boolean
http_host:
type: string
http_method:
type: string
http_url:
type: string
is_remote:
type: string
kind_string:
type: string
level:
minimum: 0
type: integer
name:
type: string
parent_span_id:
type: string
references:
items:
$ref: '#/components/schemas/SpantypesOtelSpanRef'
type: array
resource:
additionalProperties:
type: string
nullable: true
type: object
response_status_code:
type: string
span_id:
type: string
status_code:
type: integer
status_code_string:
type: string
status_message:
type: string
sub_tree_node_count:
minimum: 0
type: integer
time_unix:
minimum: 0
type: integer
trace_id:
type: string
trace_state:
type: string
required:
- references
type: object
SpantypesUpdatableSpanMapper:
properties:
config:
@@ -15685,6 +15821,81 @@ paths:
tags:
- tracedetail
x-signoz-stability: alpha
/api/v1/traces/{traceID}/thread:
get:
deprecated: false
description: Returns the spans carrying gen_ai input or output messages in timestamp
order, each with the messages normalised into formatted_input and formatted_output.
Pages are fetched with the returned nextCursor.
operationId: GetTraceThread
parameters:
- in: query
name: limit
schema:
type: integer
- in: query
name: cursor
schema:
type: string
- in: path
name: traceID
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableTraceThread'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get thread view for a trace
tags:
- tracedetail
x-signoz-stability: alpha
/api/v1/user/me:
get:
deprecated: true

View File

@@ -12,14 +12,13 @@ import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
import NotFound from 'components/NotFound';
import { ShiftHoldOverlayController } from 'components/ShiftOverlay/ShiftHoldOverlayController';
import Spinner from 'components/Spinner';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import AppLayout from 'container/AppLayout';
import Hex from 'crypto-js/enc-hex';
import HmacSHA256 from 'crypto-js/hmac-sha256';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { StatusCodes } from 'http-status-codes';
@@ -59,6 +58,7 @@ function App(): JSX.Element {
isFetchingActiveLicense,
activeLicenseFetchError,
userFetchError,
featureFlagsFetchError,
isLoggedIn: isLoggedInState,
featureFlags,
org,
@@ -66,8 +66,6 @@ function App(): JSX.Element {
} = useAppContext();
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const isSavedViewEnabled = useSavedViewEnabled();
const chatSupport = useChatSupport();
const { hostname } = window.location;
const [pathname, setPathname] = useState(history.location.pathname);
@@ -255,9 +253,7 @@ function App(): JSX.Element {
}, [isDarkMode]);
useEffect(() => {
// The bottom strip carries Support, so the floating bubble goes entirely.
if (
isSavedViewEnabled ||
pathname === ROUTES.ONBOARDING ||
pathname.startsWith('/public/dashboard/') ||
pathname === '/ai-assistant' ||
@@ -267,32 +263,71 @@ function App(): JSX.Element {
} else {
window.Pylon?.('showChatBubble');
}
}, [pathname, isSavedViewEnabled]);
}, [pathname]);
// Identity for the Pylon widget. Whether this user gets Pylon at all is
// `useChatSupport`'s call — this only fills in who they are.
// eslint-disable-next-line sonarjs/cognitive-complexity
useEffect(() => {
if (chatSupport !== ChatSupportState.Pylon) {
return;
// feature flag shouldn't be loading and featureFlags or fetchError any one of this should be true indicating that req is complete
// licenses should also be present. there is no check for licenses for loading and error as that is mandatory if not present then routing
// to something went wrong which would ideally need a reload.
if (
!isFetchingFeatureFlags &&
(featureFlags || featureFlagsFetchError) &&
activeLicense &&
trialInfo
) {
let isChatSupportEnabled = false;
let isPremiumSupportEnabled = false;
if (featureFlags && featureFlags.length > 0) {
isChatSupportEnabled =
featureFlags.find((flag) => flag.name === FeatureKeys.CHAT_SUPPORT)
?.active || false;
isPremiumSupportEnabled =
featureFlags.find((flag) => flag.name === FeatureKeys.PREMIUM_SUPPORT)
?.active || false;
}
const showAddCreditCardModal =
!isPremiumSupportEnabled && !trialInfo?.trialConvertedToSubscription;
if (
isLoggedInState &&
isChatSupportEnabled &&
!showAddCreditCardModal &&
(isCloudUser || isEnterpriseSelfHostedUser) &&
window.signozBootData?.settings?.pylon?.enabled
) {
const email = user.email || '';
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
let emailHash = '';
if (email && secret) {
emailHash = HmacSHA256(email, Hex.parse(secret)).toString(Hex);
}
window.pylon = {
chat_settings: {
app_id: window.signozBootData?.settings?.pylon?.appId,
email: user.email,
name: user.displayName || user.email,
email_hash: emailHash,
},
};
}
}
const email = user.email || '';
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
let emailHash = '';
if (email && secret) {
emailHash = HmacSHA256(email, Hex.parse(secret)).toString(Hex);
}
window.pylon = {
chat_settings: {
app_id: window.signozBootData?.settings?.pylon?.appId,
email: user.email,
name: user.displayName || user.email,
email_hash: emailHash,
},
};
}, [chatSupport, user]);
}, [
isLoggedInState,
user,
pathname,
trialInfo?.trialConvertedToSubscription,
featureFlags,
isFetchingFeatureFlags,
featureFlagsFetchError,
activeLicense,
trialInfo,
isCloudUser,
isEnterpriseSelfHostedUser,
]);
useEffect(() => {
if (!isFetchingUser && isCloudUser && user && user.email) {

View File

@@ -4,6 +4,53 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
export enum AiobservabilitytypesPartTypeDTO {
text = 'text',
thinking = 'thinking',
tool_call = 'tool_call',
tool_result = 'tool_result',
generic = 'generic',
}
export interface AiobservabilitytypesPartDTO {
arguments?: unknown;
/**
* @type string
*/
content?: string;
/**
* @type string
*/
id?: string;
/**
* @type boolean
*/
isError?: boolean;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
toolCallId?: string;
type: AiobservabilitytypesPartTypeDTO;
}
export interface AiobservabilitytypesMessageDTO {
/**
* @type array
*/
content: AiobservabilitytypesPartDTO[];
/**
* @type string
*/
finishReason?: string;
/**
* @type string
*/
role?: string;
}
export interface AlertmanagertypesChannelDTO {
/**
* @type string
@@ -11142,6 +11189,165 @@ export interface SpantypesOtelSpanRefDTO {
traceId?: string;
}
export type SpantypesThreadSpanDTOAttributesAnyOf = { [key: string]: unknown };
/**
* @nullable
*/
export type SpantypesThreadSpanDTOAttributes =
SpantypesThreadSpanDTOAttributesAnyOf | null;
export type SpantypesThreadSpanDTOResourceAnyOf = { [key: string]: string };
/**
* @nullable
*/
export type SpantypesThreadSpanDTOResource =
SpantypesThreadSpanDTOResourceAnyOf | null;
export interface SpantypesThreadSpanDTO {
/**
* @type object,null
*/
attributes?: SpantypesThreadSpanDTOAttributes;
/**
* @type string
*/
db_name?: string;
/**
* @type string
*/
db_operation?: string;
/**
* @type integer
* @minimum 0
*/
duration_nano?: number;
/**
* @type array,null
*/
events?: SpantypesEventDTO[] | null;
/**
* @type string
*/
external_http_method?: string;
/**
* @type string
*/
external_http_url?: string;
/**
* @type integer
* @minimum 0
*/
flags?: number;
/**
* @type array
*/
formatted_input?: AiobservabilitytypesMessageDTO[];
/**
* @type array
*/
formatted_output?: AiobservabilitytypesMessageDTO[];
/**
* @type boolean
*/
has_children?: boolean;
/**
* @type boolean
*/
has_error?: boolean;
/**
* @type string
*/
http_host?: string;
/**
* @type string
*/
http_method?: string;
/**
* @type string
*/
http_url?: string;
/**
* @type string
*/
is_remote?: string;
/**
* @type string
*/
kind_string?: string;
/**
* @type integer
* @minimum 0
*/
level?: number;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
parent_span_id?: string;
/**
* @type array
*/
references: SpantypesOtelSpanRefDTO[];
/**
* @type object,null
*/
resource?: SpantypesThreadSpanDTOResource;
/**
* @type string
*/
response_status_code?: string;
/**
* @type string
*/
span_id?: string;
/**
* @type integer
*/
status_code?: number;
/**
* @type string
*/
status_code_string?: string;
/**
* @type string
*/
status_message?: string;
/**
* @type integer
* @minimum 0
*/
sub_tree_node_count?: number;
/**
* @type integer
* @minimum 0
*/
time_unix?: number;
/**
* @type string
*/
trace_id?: string;
/**
* @type string
*/
trace_state?: string;
}
export interface SpantypesGettableTraceThreadDTO {
/**
* @type string
*/
nextCursor?: string;
/**
* @type array
*/
spans: SpantypesThreadSpanDTO[];
}
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
[key: string]: unknown;
};
@@ -12815,6 +13021,30 @@ export type GetTraceAggregations200 = {
status: string;
};
export type GetTraceThreadPathParameters = {
traceID: string;
};
export type GetTraceThreadParams = {
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type string
* @description undefined
*/
cursor?: string;
};
export type GetTraceThread200 = {
data: SpantypesGettableTraceThreadDTO;
/**
* @type string
*/
status: string;
};
export type ListUserPreferences200 = {
/**
* @type array

View File

@@ -4,11 +4,17 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation } from 'react-query';
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
@@ -16,6 +22,9 @@ import type {
GetFlamegraphPathParameters,
GetTraceAggregations200,
GetTraceAggregationsPathParameters,
GetTraceThread200,
GetTraceThreadParams,
GetTraceThreadPathParameters,
GetWaterfallV4200,
GetWaterfallV4PathParameters,
RenderErrorResponseDTO,
@@ -27,6 +36,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* Computes span aggregations grouped by requested field.
* @summary Get aggregations for a trace
@@ -127,6 +156,121 @@ export const useGetTraceAggregations = <
> => {
return useMutation(getGetTraceAggregationsMutationOptions(options));
};
/**
* Returns the spans carrying gen_ai input or output messages in timestamp order, each with the messages normalised into formatted_input and formatted_output. Pages are fetched with the returned nextCursor.
* @summary Get thread view for a trace
*/
export const getTraceThread = (
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetTraceThread200>({
url: `/api/v1/traces/${traceID}/thread`,
method: 'GET',
params,
signal,
});
};
export const getGetTraceThreadQueryKey = (
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
) => {
return [
`/api/v1/traces/${traceID}/thread`,
...(params ? [params] : []),
] as const;
};
export const getGetTraceThreadQueryOptions = <
TData = Awaited<ReturnType<typeof getTraceThread>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceThread>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetTraceThreadQueryKey({ traceID }, params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceThread>>> = ({
signal,
}) => getTraceThread({ traceID }, params, signal);
return {
queryKey,
queryFn,
enabled: traceID !== null && traceID !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getTraceThread>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetTraceThreadQueryResult = NonNullable<
Awaited<ReturnType<typeof getTraceThread>>
>;
export type GetTraceThreadQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get thread view for a trace
*/
export function useGetTraceThread<
TData = Awaited<ReturnType<typeof getTraceThread>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceThread>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetTraceThreadQueryOptions(
{ traceID },
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Get thread view for a trace
*/
export const invalidateGetTraceThread = async (
queryClient: QueryClient,
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetTraceThreadQueryKey({ traceID }, params) },
options,
);
return queryClient;
};
/**
* Returns the flamegraph view of spans for a given trace ID.
* @summary Get flamegraph view for a trace

View File

@@ -1,103 +0,0 @@
import { useMutation } from 'react-query';
import { Button, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { createSubscription } from 'api/generated/services/subscriptions';
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { CreditCard, X } from '@signozhq/icons';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
interface AddCreditCardModalProps {
open: boolean;
onClose: () => void;
onAddCreditCard?: () => void;
}
/**
* Shown to trial users who have not added a card, in place of chat support.
* Submitting creates the subscription and opens the returned billing URL.
*/
function AddCreditCardModal({
open,
onClose,
onAddCreditCard,
}: AddCreditCardModalProps): JSX.Element {
const { notifications } = useNotifications();
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
newTab.target = '_blank';
newTab.rel = 'noopener noreferrer';
newTab.click();
}
};
const handleBillingOnError = (error: APIError): void => {
notifications.error({
message: error.getErrorCode(),
description: error.getErrorMessage(),
});
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
createSubscription,
{ onSuccess: handleBillingOnSuccess, onError: handleBillingOnError },
);
const handleAddCreditCard = (): void => {
onAddCreditCard?.();
updateCreditCard({ url: getBaseUrl() });
};
return (
<Modal
className="add-credit-card-modal"
title={<span className="title">Add Credit Card for Chat Support</span>}
open={open}
closable
onCancel={onClose}
destroyOnClose
footer={[
<Button
key="cancel"
onClick={onClose}
className="cancel-btn"
icon={<X size={16} />}
>
Cancel
</Button>,
<AuthZTooltip
key="submit"
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
<Button
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
>
Add Credit Card
</Button>
</AuthZTooltip>,
]}
>
<Typography.Text className="add-credit-card-text">
You&apos;re currently on <span className="highlight-text">Trial plan</span>.
Add a credit card to access SigNoz chat support to your workspace.
</Typography.Text>
</Modal>
);
}
AddCreditCardModal.defaultProps = { onAddCreditCard: undefined };
export default AddCreditCardModal;

View File

@@ -1,9 +0,0 @@
import { rest } from 'msw';
const SUBSCRIPTIONS = 'http://localhost/api/v1/subscriptions';
export const createSubscriptionHandlers = [
rest.post(SUBSCRIPTIONS, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: { redirectURL: '' } })),
),
];

View File

@@ -1,54 +0,0 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from '@signozhq/ui/button';
import { screen, userEvent, within } from 'storybook/test';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import { createSubscriptionHandlers } from './AddCreditCardModal.stories.mocks';
import AddCreditCardModal from '../AddCreditCardModal';
/** Stands in for the surfaces that open it: the strip's Support button, and
* `LaunchChatSupport` in the custom domain and onboarding flows. */
function ModalFixture(): JSX.Element {
const [open, setOpen] = useState(false);
return (
<>
<Button
data-testid="open-add-credit-card"
onClick={(): void => setOpen(true)}
>
Contact support
</Button>
<AddCreditCardModal open={open} onClose={(): void => setOpen(false)} />
</>
);
}
const meta = {
title: 'Components/Add Credit Card Modal',
component: ModalFixture,
tags: ['play'],
decorators: [withCanvas({ maxWidth: 400 })],
parameters: {
msw: { handlers: createSubscriptionHandlers },
},
} satisfies Meta<typeof ModalFixture>;
export default meta;
type Story = StoryObj<typeof meta>;
/** What a trial user without a card sees in place of chat support. */
export const Default: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
within(canvasElement).getByTestId('open-add-credit-card'),
);
await screen.findByRole('dialog', {
name: /Add Credit Card for Chat Support/i,
});
},
};

View File

@@ -1,15 +1,63 @@
import { useState } from 'react';
import { useMutation } from 'react-query';
import { useLocation } from 'react-router-dom';
import { Button } from 'antd';
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
import { Button, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { MessageSquareText } from '@signozhq/icons';
import { createSubscription } from 'api/generated/services/subscriptions';
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { CreditCard, MessageSquareText, X } from '@signozhq/icons';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
export default function ChatSupportGateway(): JSX.Element {
const { notifications } = useNotifications();
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
useState(false);
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
newTab.target = '_blank';
newTab.rel = 'noopener noreferrer';
newTab.click();
}
};
const handleBillingOnError = (error: APIError): void => {
notifications.error({
message: error.getErrorCode(),
description: error.getErrorMessage(),
});
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
createSubscription,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
},
onError: handleBillingOnError,
},
);
const { pathname } = useLocation();
const handleAddCreditCard = (): void => {
logEvent('Add Credit card modal: Clicked', {
source: `chat support icon`,
page: pathname,
});
updateCreditCard({
url: getBaseUrl(),
});
};
return (
<>
<div className="chat-support-gateway">
@@ -28,16 +76,47 @@ export default function ChatSupportGateway(): JSX.Element {
</Button>
</div>
<AddCreditCardModal
{/* Add Credit Card Modal */}
<Modal
className="add-credit-card-modal"
title={<span className="title">Add Credit Card for Chat Support</span>}
open={isAddCreditCardModalOpen}
onClose={(): void => setIsAddCreditCardModalOpen(false)}
onAddCreditCard={(): void => {
logEvent('Add Credit card modal: Clicked', {
source: `chat support icon`,
page: pathname,
});
}}
/>
closable
onCancel={(): void => setIsAddCreditCardModalOpen(false)}
destroyOnClose
footer={[
<Button
key="cancel"
onClick={(): void => setIsAddCreditCardModalOpen(false)}
className="cancel-btn"
icon={<X size={16} />}
>
Cancel
</Button>,
<AuthZTooltip
key="submit"
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
<Button
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
>
Add Credit Card
</Button>
</AuthZTooltip>,
]}
>
<Typography.Text className="add-credit-card-text">
You&apos;re currently on <span className="highlight-text">Trial plan</span>
. Add a credit card to access SigNoz chat support to your workspace.
</Typography.Text>
</Modal>
</>
);
}

View File

@@ -1,14 +1,22 @@
import { useMemo, useState } from 'react';
import { useMutation } from 'react-query';
import { useLocation } from 'react-router-dom';
import { Button, Tooltip } from 'antd';
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
import { Button, Modal, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { createSubscription } from 'api/generated/services/subscriptions';
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { FeatureKeys } from 'constants/features';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { defaultTo } from 'lodash-es';
import { CircleHelp } from '@signozhq/icons';
import { CircleHelp, CreditCard, X } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
import './LaunchChatSupport.styles.scss';
@@ -33,6 +41,7 @@ function LaunchChatSupport({
chatMessageDisabled = false,
}: LaunchChatSupportProps): JSX.Element | null {
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const { notifications } = useNotifications();
const {
trialInfo,
featureFlags,
@@ -110,12 +119,43 @@ function LaunchChatSupport({
}
};
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
newTab.target = '_blank';
newTab.rel = 'noopener noreferrer';
newTab.click();
}
};
const handleBillingOnError = (error: APIError): void => {
notifications.error({
message: error.getErrorCode(),
description: error.getErrorMessage(),
});
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
createSubscription,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
},
onError: handleBillingOnError,
},
);
const handleAddCreditCard = (): void => {
logEvent('Add Credit card modal: Clicked', {
source: `facing issues button`,
page: pathname,
...attributes,
});
updateCreditCard({
url: getBaseUrl(),
});
};
return isCloudUserVal && isChatSupportEnabled ? ( // Note: we would need to move this condition to license based in future
@@ -135,11 +175,47 @@ function LaunchChatSupport({
</Button>
</Tooltip>
<AddCreditCardModal
{/* Add Credit Card Modal */}
<Modal
className="add-credit-card-modal"
title={<span className="title">Add Credit Card for Chat Support</span>}
open={isAddCreditCardModalOpen}
onClose={(): void => setIsAddCreditCardModalOpen(false)}
onAddCreditCard={handleAddCreditCard}
/>
closable
onCancel={(): void => setIsAddCreditCardModalOpen(false)}
destroyOnClose
footer={[
<Button
key="cancel"
onClick={(): void => setIsAddCreditCardModalOpen(false)}
className="cancel-btn"
icon={<X size={16} />}
>
Cancel
</Button>,
<AuthZTooltip
key="submit"
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
<Button
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
>
Add Credit Card
</Button>
</AuthZTooltip>,
]}
>
<Typography.Text className="add-credit-card-text">
You&apos;re currently on <span className="highlight-text">Trial plan</span>
. Add a credit card to access SigNoz chat support to your workspace.
</Typography.Text>
</Modal>
</div>
) : null;
}

View File

@@ -885,7 +885,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
)}
</Flex>
{showAddCreditCardModal && !isSavedViewEnabled && <ChatSupportGateway />}
{showAddCreditCardModal && <ChatSupportGateway />}
{showChangelogModal && changelog && (
<ChangelogModal changelog={changelog} onClose={toggleChangelogModal} />
)}

View File

@@ -1,36 +0,0 @@
.askNoz {
display: flex;
align-items: center;
flex-shrink: 0;
}
.badge {
flex-shrink: 0;
display: inline-flex;
line-height: 0;
color: var(--accent-primary);
}
.pulseDot {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
animation: askNozDotPulse 1.5s ease-in-out infinite;
transform: scale(0.8);
margin-right: -12px;
}
@keyframes askNozDotPulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.35;
transform: scale(0.82);
}
}

View File

@@ -1,76 +0,0 @@
import { useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import Noz from 'components/Noz/Noz';
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
import {
openAIAssistant,
useAIAssistantStore,
} from 'container/AIAssistant/store/useAIAssistantStore';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import ROUTES from 'constants/routes';
import { Dot } from '@signozhq/icons';
import styles from './AskNoz.module.scss';
/**
* Opens the Noz drawer, matching the header entry it replaces. Cmd+K opens the
* modal instead; this is deliberately the drawer.
*
* Carries the header's pending badge: when Noz is blocked on the user
* (`awaiting_approval` / `awaiting_clarification`) a dot pulses. Without it,
* hiding the header button would remove a notification rather than move it.
*/
function AskNoz(): JSX.Element | null {
const { pathname } = useLocation();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const isDrawerOpen = useAIAssistantStore((state) => state.isDrawerOpen);
const isModalOpen = useAIAssistantStore((state) => state.isModalOpen);
const pendingUserInputCount = useAIAssistantStore(
selectPendingUserInputStreamCount,
);
// Noz is already on screen in the modal, so the "needs you" dot would be noise.
const showPendingBadge = pendingUserInputCount > 0 && !isModalOpen;
// The drawer does not render on the Noz full page, so the button would be inert.
const isAIAssistantPage = pathname.startsWith(ROUTES.AI_ASSISTANT_BASE);
if (!isAIAssistantEnabled || isDrawerOpen || isAIAssistantPage) {
return null;
}
return (
<div className={styles.askNoz} data-testid="bottom-strip-ask-noz">
{showPendingBadge && (
<span className={styles.badge} aria-hidden>
<span className={styles.pulseDot}>
<Dot size={36} />
</span>
</span>
)}
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
<Button
variant="ghost"
color="secondary"
size="sm"
className="noz-wave"
prefix={<Noz size={16} />}
onClick={(): void => openAIAssistant()}
aria-label={
showPendingBadge
? `Ask Noz, ${pendingUserInputCount} ${
pendingUserInputCount === 1 ? 'action needs' : 'actions need'
} your response`
: 'Ask Noz'
}
>
Ask Noz
</Button>
</TooltipSimple>
</div>
);
}
export default AskNoz;

View File

@@ -1,133 +0,0 @@
import { fireEvent } from '@testing-library/react';
import { openAIAssistant } from 'container/AIAssistant/store/useAIAssistantStore';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { render } from 'tests/test-utils';
import AskNoz from '../AskNoz';
jest.mock('hooks/useIsAIAssistantEnabled');
jest.mock('container/AIAssistant/store/useAIAssistantStore', () => ({
openAIAssistant: jest.fn(),
useAIAssistantStore: jest.fn(),
}));
const mockEnabled = useIsAIAssistantEnabled as jest.MockedFunction<
typeof useIsAIAssistantEnabled
>;
const mockOpen = openAIAssistant as jest.MockedFunction<typeof openAIAssistant>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { useAIAssistantStore } = jest.requireMock(
'container/AIAssistant/store/useAIAssistantStore',
) as { useAIAssistantStore: jest.Mock };
/** The component reads the store through three separate selector calls. */
function mockStore({
isDrawerOpen = false,
isModalOpen = false,
pendingCount = 0,
}: {
isDrawerOpen?: boolean;
isModalOpen?: boolean;
pendingCount?: number;
} = {}): void {
const state = { isDrawerOpen, isModalOpen, streams: {} };
useAIAssistantStore.mockImplementation((selector: (s: unknown) => unknown) => {
const picked = selector(state);
// `selectPendingUserInputStreamCount` walks `streams`, which is empty here,
// so stand in the count we want to assert against.
return typeof picked === 'number' ? pendingCount : picked;
});
}
// `TooltipSimple` replaces its trigger's props, so the testId lives on the
// wrapper and the button itself is reached by role — there is only ever one.
const SLOT = 'bottom-strip-ask-noz';
describe('AskNoz', () => {
beforeEach(() => {
jest.clearAllMocks();
mockEnabled.mockReturnValue(true);
mockStore();
});
describe('visibility', () => {
it('renders when the assistant is enabled', () => {
const { getByRole } = render(<AskNoz />);
expect(getByRole('button')).toBeInTheDocument();
});
it('renders nothing when the assistant is disabled', () => {
mockEnabled.mockReturnValue(false);
const { queryByTestId } = render(<AskNoz />);
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
});
it('renders nothing while the drawer is open', () => {
mockStore({ isDrawerOpen: true });
const { queryByTestId } = render(<AskNoz />);
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
});
it('renders nothing on the Noz full page, where the drawer does not mount', () => {
const { queryByTestId } = render(<AskNoz />, undefined, {
initialRoute: '/ai-assistant/some-conversation-id',
});
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
});
});
describe('what it does', () => {
it('opens the drawer on click', () => {
const { getByRole } = render(<AskNoz />);
fireEvent.click(getByRole('button'));
expect(mockOpen).toHaveBeenCalledTimes(1);
});
});
describe('pending badge', () => {
it('announces the count when Noz is waiting on the user', () => {
mockStore({ pendingCount: 2 });
const { getByRole } = render(<AskNoz />);
expect(getByRole('button')).toHaveAttribute(
'aria-label',
'Ask Noz, 2 actions need your response',
);
});
it('uses the singular for one pending action', () => {
mockStore({ pendingCount: 1 });
const { getByRole } = render(<AskNoz />);
expect(getByRole('button')).toHaveAttribute(
'aria-label',
'Ask Noz, 1 action needs your response',
);
});
it('stays quiet when nothing is pending', () => {
const { getByRole } = render(<AskNoz />);
expect(getByRole('button')).toHaveAttribute('aria-label', 'Ask Noz');
});
it('stays quiet while the modal is open, Noz is already on screen', () => {
mockStore({ isModalOpen: true, pendingCount: 3 });
const { getByRole } = render(<AskNoz />);
expect(getByRole('button')).toHaveAttribute('aria-label', 'Ask Noz');
});
});
});

View File

@@ -11,8 +11,6 @@
background: var(--l2-background);
border-top: 1px solid var(--l2-border);
--button-font-size: 12px;
font-family: var(--font-family-sf-mono, monospace);
// Above page content, below the body-portalled overlays that are meant to
@@ -25,21 +23,14 @@
.right {
display: flex;
align-items: center;
gap: var(--spacing-4);
gap: var(--spacing-6);
min-width: 0;
}
.left {
cursor: default;
}
// separator b/w strip entities
.left > *:not(:last-child)::after,
.right > *:not(:last-child)::after {
content: '';
flex-shrink: 0;
width: 1px;
height: 14px;
background: var(--l2-border);
margin-left: var(--spacing-4);
// Temporary placeholder for the left slot. Replaced later.
.version {
color: var(--l2-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -1,53 +0,0 @@
import * as Sentry from '@sentry/react';
import { useAppContext } from 'providers/App/App';
import StripKeyValue from '../components/StripKeyValue/StripKeyValue';
import StripTypography from '../components/StripTypography/StripTypography';
import { useBottomStripStore } from '../store/useBottomStripStore';
import { type StripItem, StripItemKind } from '../types';
function renderItem(item: StripItem, index: number): JSX.Element {
if (item.kind === StripItemKind.KeyValue) {
return (
<StripKeyValue
key={index}
label={item.label}
value={item.value}
prefix={item.prefix}
tone={item.tone}
/>
);
}
return (
// eslint-disable-next-line react/no-array-index-key
<StripTypography key={index} prefix={item.prefix}>
{item.text}
</StripTypography>
);
}
function LeftSlot(): JSX.Element | null {
const { versionData } = useAppContext();
const left = useBottomStripStore((state) => state.left);
const ownerId = useBottomStripStore((state) => state.ownerId);
const version = versionData?.version?.trim();
const versionNode = version ? (
<StripTypography>{version}</StripTypography>
) : null;
if (!left?.length) {
return versionNode;
}
return (
// Keyed so a page whose items throw does not leave the boundary latched on
// the fallback for every page after it.
<Sentry.ErrorBoundary key={ownerId} fallback={<>{versionNode}</>}>
{left.map(renderItem)}
</Sentry.ErrorBoundary>
);
}
export default LeftSlot;

View File

@@ -1,45 +0,0 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { MessageSquareText } from '@signozhq/icons';
function SupportButton(): JSX.Element | null {
const chatSupport = useChatSupport();
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
useState(false);
if (chatSupport === ChatSupportState.Unavailable) {
return null;
}
const handleClick = (): void => {
if (chatSupport === ChatSupportState.NeedsCard) {
setIsAddCreditCardModalOpen(true);
return;
}
window.Pylon?.('show');
};
return (
<>
<Button
variant="ghost"
color="secondary"
size="sm"
prefix={<MessageSquareText size={16} />}
onClick={handleClick}
testId="bottom-strip-support"
>
Support
</Button>
<AddCreditCardModal
open={isAddCreditCardModalOpen}
onClose={(): void => setIsAddCreditCardModalOpen(false)}
/>
</>
);
}
export default SupportButton;

View File

@@ -1,81 +0,0 @@
import { fireEvent } from '@testing-library/react';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { render } from 'tests/test-utils';
import SupportButton from '../SupportButton';
jest.mock('hooks/useChatSupport', () => ({
...jest.requireActual('hooks/useChatSupport'),
useChatSupport: jest.fn(),
}));
const mockChatSupport = useChatSupport as jest.MockedFunction<
typeof useChatSupport
>;
const BUTTON = 'bottom-strip-support';
const MODAL_TITLE = 'Add Credit Card for Chat Support';
describe('SupportButton', () => {
beforeEach(() => {
jest.clearAllMocks();
window.Pylon = jest.fn() as never;
});
describe('when Pylon is available', () => {
beforeEach(() => mockChatSupport.mockReturnValue(ChatSupportState.Pylon));
it('shows the button', () => {
const { getByTestId } = render(<SupportButton />);
expect(getByTestId(BUTTON)).toBeInTheDocument();
});
it('opens the Pylon widget on click', () => {
const { getByTestId } = render(<SupportButton />);
fireEvent.click(getByTestId(BUTTON));
expect(window.Pylon).toHaveBeenCalledWith('show');
});
it('does not open the credit card modal', () => {
const { getByTestId, queryByText } = render(<SupportButton />);
fireEvent.click(getByTestId(BUTTON));
expect(queryByText(MODAL_TITLE)).not.toBeInTheDocument();
});
});
describe('when the user needs a card', () => {
beforeEach(() => mockChatSupport.mockReturnValue(ChatSupportState.NeedsCard));
it('shows the same button', () => {
const { getByTestId } = render(<SupportButton />);
expect(getByTestId(BUTTON)).toBeInTheDocument();
});
it('opens the credit card modal on click, not Pylon', () => {
const { getByTestId, getByText } = render(<SupportButton />);
fireEvent.click(getByTestId(BUTTON));
expect(getByText(MODAL_TITLE)).toBeInTheDocument();
expect(window.Pylon).not.toHaveBeenCalled();
});
});
describe('when support is unavailable', () => {
beforeEach(() =>
mockChatSupport.mockReturnValue(ChatSupportState.Unavailable),
);
it('renders nothing at all', () => {
const { queryByTestId } = render(<SupportButton />);
expect(queryByTestId(BUTTON)).not.toBeInTheDocument();
});
});
});

View File

@@ -1,65 +1,12 @@
import { useMemo, useState } from 'react';
import { fireEvent } from '@testing-library/react';
import { render } from 'tests/test-utils';
import { Info } from 'types/api/v1/version/get';
import BottomStrip, {
BOTTOM_STRIP_HEIGHT,
BOTTOM_STRIP_HEIGHT_VAR,
BOTTOM_STRIP_ON_CLASS,
} from '..';
import { useBottomStripStore } from '../store/useBottomStripStore';
import { useBottomStrip } from '../useBottomStrip';
import { StripItemKind } from '../types';
/** Stands in for a page that puts something on the left of the strip. */
function Page({ text }: { text: string }): null {
useBottomStrip(useMemo(() => [{ kind: StripItemKind.Text, text }], [text]));
return null;
}
/**
* A page whose icon throws when the strip renders it. `prefix` is the one place
* the config still takes a node, so it is the only way a page can break it.
*/
function BoomPage(): null {
useBottomStrip([{ kind: StripItemKind.Text, text: 'boom', prefix: <Boom /> }]);
return null;
}
/** A page whose value changes without needing `rerender`. */
function ChangingPage(): JSX.Element {
const [count, setCount] = useState(600);
useBottomStrip(
useMemo(
() => [{ kind: StripItemKind.Text, text: `${count} traces` }],
[count],
),
);
return (
<button type="button" onClick={(): void => setCount(42)}>
change
</button>
);
}
/** A page node that blows up while the strip renders it. */
function Boom(): JSX.Element {
throw new Error('bad left node');
}
const VERSION = 'v0.134.67';
const versionData: Info = { version: VERSION, ee: 'Y', setupCompleted: true };
const withVersion = { appContextOverrides: { versionData } };
describe('BottomStrip', () => {
// The store is module level, so it outlives each test.
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('publishes the body class and height property while mounted', () => {
const { unmount } = render(<BottomStrip />);
@@ -81,96 +28,22 @@ describe('BottomStrip', () => {
it.each([['v0.134.67'], ['main-64f1c2a']])(
'renders the build version %p exactly as given',
(version) => {
const { getByText } = render(<BottomStrip />, undefined, {
const { getByTestId } = render(<BottomStrip />, undefined, {
appContextOverrides: {
versionData: { version, ee: 'Y', setupCompleted: true },
},
});
expect(getByText(version)).toBeInTheDocument();
expect(getByTestId('bottom-strip-version')).toHaveTextContent(version);
},
);
it('renders the strip without a version when none is available', () => {
const { getByTestId } = render(<BottomStrip />, undefined, {
const { getByTestId, queryByTestId } = render(<BottomStrip />, undefined, {
appContextOverrides: { versionData: null },
});
const strip = getByTestId('bottom-strip');
expect(strip).toBeInTheDocument();
expect(strip).toHaveTextContent('');
});
// `tests/test-utils` builds its wrapper around the first `ui`, so `rerender`
// re-renders the original tree. These drive change through state and through
// separate trees instead, which the module-level store lets them share.
describe('left slot', () => {
it('shows what the page put there instead of the version', () => {
render(<Page text="600 traces" />);
const { getByText, queryByText } = render(
<BottomStrip />,
undefined,
withVersion,
);
expect(getByText('600 traces')).toBeInTheDocument();
expect(queryByText(VERSION)).not.toBeInTheDocument();
});
it('falls back to the version once the page is gone', () => {
const page = render(<Page text="600 traces" />);
const { getByText } = render(<BottomStrip />, undefined, withVersion);
page.unmount();
expect(getByText(VERSION)).toBeInTheDocument();
});
it('updates when the page changes what it shows', () => {
render(<ChangingPage />);
const { getByText, getByRole } = render(
<BottomStrip />,
undefined,
withVersion,
);
expect(getByText('600 traces')).toBeInTheDocument();
fireEvent.click(getByRole('button', { name: 'change' }));
expect(getByText('42 traces')).toBeInTheDocument();
});
it('falls back to the version when the page node throws', () => {
// React logs the caught error, which is noise here.
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
render(<BoomPage />);
const { getByTestId, getByText } = render(
<BottomStrip />,
undefined,
withVersion,
);
expect(getByTestId('bottom-strip')).toBeInTheDocument();
expect(getByText(VERSION)).toBeInTheDocument();
consoleError.mockRestore();
});
it('keeps the new page value when the old page unmounts after it', () => {
// Navigation order: the next page mounts before the last one unmounts,
// so without the owner guard the outgoing page wipes the incoming value.
const pageA = render(<Page text="page A" />);
render(<Page text="page B" />);
const { getByText } = render(<BottomStrip />, undefined, withVersion);
pageA.unmount();
expect(getByText('page B')).toBeInTheDocument();
});
expect(getByTestId('bottom-strip')).toBeInTheDocument();
expect(queryByTestId('bottom-strip-version')).not.toBeInTheDocument();
});
});

View File

@@ -1,24 +0,0 @@
// `:` is spaced like a word, per the Figma, so the gap here is a space width
// rather than the wider gap between whole items.
.keyValue {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
color: var(--l2-foreground);
white-space: nowrap;
}
.prefix {
display: inline-flex;
align-items: center;
margin-right: var(--spacing-1);
}
.warning {
color: var(--warning);
}
.error {
color: var(--destructive);
}

View File

@@ -1,47 +0,0 @@
import type { ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { StripTone } from '../../types';
import styles from './StripKeyValue.module.scss';
export interface StripKeyValueProps {
label: string;
value: string | number;
/** Leading icon. Same shape as `Button`'s. */
prefix?: ReactNode;
/** Tints the icon; the text stays neutral at every tone. */
tone?: StripTone;
}
/** A labelled number, rendered `Spans : 31`. */
function StripKeyValue({
label,
value,
prefix,
tone,
}: StripKeyValueProps): JSX.Element {
return (
<span className={styles.keyValue}>
{prefix && (
<span className={cx(styles.prefix, tone && styles[tone])} data-tone={tone}>
{prefix}
</span>
)}
<Typography.Text as="span" className={styles.label}>
{label}
</Typography.Text>
<Typography.Text as="span" className={styles.colon}>
:
</Typography.Text>
<Typography.Text as="span" className={styles.value}>
{value}
</Typography.Text>
</span>
);
}
StripKeyValue.defaultProps = { prefix: undefined, tone: StripTone.Default };
export default StripKeyValue;

View File

@@ -1,46 +0,0 @@
import { render } from 'tests/test-utils';
import { StripTone } from '../../../types';
import StripKeyValue from '../StripKeyValue';
describe('StripKeyValue', () => {
it('renders the label and the value around a colon it owns', () => {
const { getByText, container } = render(
<StripKeyValue label="Spans" value={31} />,
);
expect(getByText('Spans')).toBeInTheDocument();
expect(getByText('31')).toBeInTheDocument();
// The consumer never builds the string, so the spacing cannot drift.
expect(container.textContent).toBe('Spans:31');
});
it('tints only the icon when the tone is set', () => {
const { container } = render(
<StripKeyValue
label="Errors"
value={4}
tone={StripTone.Error}
prefix={<svg data-testid="icon" />}
/>,
);
// The tone lands on the icon, never on the text.
expect(container.querySelector('[data-tone]')).toHaveAttribute(
'data-tone',
'error',
);
});
it('leaves the icon untinted by default', () => {
const { container } = render(
<StripKeyValue label="Errors" value={0} prefix={<svg />} />,
);
expect(container.querySelector('[data-tone]')).toHaveAttribute(
'data-tone',
'default',
);
});
});

View File

@@ -1,10 +0,0 @@
.stripTypography {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--l2-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -1,28 +0,0 @@
import type { ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import styles from './StripTypography.module.scss';
interface StripTypographyProps {
children: ReactNode;
/** Leading icon. Same shape as `Button`'s. */
prefix?: ReactNode;
}
/** A single phrase. A labelled number belongs in `StripKeyValue`. */
function StripTypography({
children,
prefix,
}: StripTypographyProps): JSX.Element {
return (
<span className={styles.stripTypography}>
{prefix}
<Typography.Text as="span">{children}</Typography.Text>
</span>
);
}
StripTypography.defaultProps = { prefix: undefined };
export type { StripTypographyProps };
export default StripTypography;

View File

@@ -1,8 +1,5 @@
import { useLayoutEffect } from 'react';
import AskNoz from './AskNoz/AskNoz';
import LeftSlot from './LeftSlot/LeftSlot';
import SupportButton from './SupportButton/SupportButton';
import { useAppContext } from 'providers/App/App';
import styles from './BottomStrip.module.scss';
@@ -12,6 +9,9 @@ export const BOTTOM_STRIP_ON_CLASS = 'bottom-strip-on';
export const BOTTOM_STRIP_HEIGHT_VAR = '--bottom-strip-height';
function BottomStrip(): JSX.Element {
const { versionData } = useAppContext();
const version = versionData?.version?.trim();
useLayoutEffect(() => {
document.body.classList.add(BOTTOM_STRIP_ON_CLASS);
document.body.style.setProperty(
@@ -28,12 +28,13 @@ function BottomStrip(): JSX.Element {
return (
<div className={styles.strip} data-testid="bottom-strip">
<div className={styles.left}>
<LeftSlot />
</div>
<div className={styles.right}>
<AskNoz />
<SupportButton />
{version && (
<span className={styles.version} data-testid="bottom-strip-version">
{version}
</span>
)}
</div>
<div className={styles.right} />
</div>
);
}

View File

@@ -1,27 +0,0 @@
import { create } from 'zustand';
import type { StripItem } from '../types';
interface BottomStripState {
/** What the strip shows on the left, or null to fall back to the version. */
left: StripItem[] | null;
/** Which page owns the current value — see `clearLeft`. */
ownerId: string | null;
setLeft: (ownerId: string, left: StripItem[] | null) => void;
clearLeft: (ownerId: string) => void;
}
export const useBottomStripStore = create<BottomStripState>()((set, get) => ({
left: null,
ownerId: null,
setLeft: (ownerId, left): void => set({ left, ownerId }),
// Only the current owner may clear. On a plain route swap React runs the old
// page's cleanup before the new page's effect, so this is moot, but two
// consumers can be mounted at once (a page under a drawer): the one that set
// last owns the slot, and the other unmounting must not wipe it.
clearLeft: (ownerId): void => {
if (get().ownerId === ownerId) {
set({ left: null, ownerId: null });
}
},
}));

View File

@@ -1,31 +0,0 @@
import type { ReactNode } from 'react';
export enum StripTone {
Default = 'default',
Warning = 'warning',
Error = 'error',
}
export enum StripItemKind {
/** A phrase in the page's own words, e.g. "18 services". */
Text = 'text',
/** A labelled number, rendered `label : value`. */
KeyValue = 'keyValue',
}
/** What a page may put on the left of the strip. */
export type StripItem =
| {
kind: StripItemKind.Text;
text: string;
prefix?: ReactNode;
}
| {
kind: StripItemKind.KeyValue;
/** Without a colon; the strip renders the separator. */
label: string;
value: string | number;
prefix?: ReactNode;
/** Tints the icon; the text stays neutral at every tone. */
tone?: StripTone;
};

View File

@@ -1,25 +0,0 @@
import { useEffect, useId } from 'react';
import { useBottomStripStore } from './store/useBottomStripStore';
import type { StripItem } from './types';
/**
* Puts `items` in the bottom strip for as long as the calling page is mounted.
* Pass null to show nothing and let the version through. The left is the only
* side a page contributes to; the right is the strip's own.
*
* There is no refresh API by design: a page that refetches re-renders, which
* produces new items, which re-runs this effect. Memoise the array where it is
* built, or the store is written on every render.
*/
export function useBottomStrip(items: StripItem[] | null): void {
const ownerId = useId();
const setLeft = useBottomStripStore((state) => state.setLeft);
const clearLeft = useBottomStripStore((state) => state.clearLeft);
useEffect(() => {
setLeft(ownerId, items);
return (): void => clearLeft(ownerId);
}, [items, ownerId, setLeft, clearLeft]);
}

View File

@@ -1,148 +0,0 @@
import { renderHook } from '@testing-library/react';
import { FeatureKeys } from 'constants/features';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { useAppContext } from 'providers/App/App';
jest.mock('providers/App/App');
jest.mock('hooks/useGetTenantLicense');
const mockAppContext = useAppContext as jest.MockedFunction<
typeof useAppContext
>;
const mockLicense = useGetTenantLicense as jest.MockedFunction<
typeof useGetTenantLicense
>;
const flag = (name: FeatureKeys, active: boolean): Record<string, unknown> => ({
name,
active,
usage: 0,
usage_limit: -1,
route: '',
});
function setup({
chatSupport = true,
premiumSupport = false,
trialConverted = false,
isCloudUser = true,
isEnterpriseSelfHostedUser = false,
isLoggedIn = true,
pylonEnabled = true,
isFetchingFeatureFlags = false,
activeLicense = {} as unknown,
trialInfo = {} as unknown,
} = {}): void {
window.signozBootData = {
settings: { pylon: { enabled: pylonEnabled } },
} as never;
mockAppContext.mockReturnValue({
featureFlags: [
flag(FeatureKeys.CHAT_SUPPORT, chatSupport),
flag(FeatureKeys.PREMIUM_SUPPORT, premiumSupport),
],
isFetchingFeatureFlags,
featureFlagsFetchError: null,
trialInfo: trialInfo && { trialConvertedToSubscription: trialConverted },
isLoggedIn,
activeLicense,
} as never);
mockLicense.mockReturnValue({
isCloudUser,
isEnterpriseSelfHostedUser,
} as never);
}
const state = (): ChatSupportState =>
renderHook(() => useChatSupport()).result.current;
describe('useChatSupport', () => {
beforeEach(() => jest.clearAllMocks());
describe('pylon', () => {
it('hands off to Pylon for a cloud user past trial', () => {
setup({ trialConverted: true });
expect(state()).toBe(ChatSupportState.Pylon);
});
it('hands off to Pylon for enterprise self-hosted', () => {
setup({
trialConverted: true,
isCloudUser: false,
isEnterpriseSelfHostedUser: true,
});
expect(state()).toBe(ChatSupportState.Pylon);
});
it('hands off to Pylon when premium support is on, card or not', () => {
setup({ premiumSupport: true, trialConverted: false });
expect(state()).toBe(ChatSupportState.Pylon);
});
it('offers nothing when Pylon is not configured server side', () => {
setup({ trialConverted: true, pylonEnabled: false });
expect(state()).toBe(ChatSupportState.Unavailable);
});
});
describe('needsCard', () => {
it('offers the card flow to a cloud user still on trial', () => {
setup({ trialConverted: false, premiumSupport: false });
expect(state()).toBe(ChatSupportState.NeedsCard);
});
it('offers nothing to a non-cloud user needing a card', () => {
setup({
trialConverted: false,
isCloudUser: false,
isEnterpriseSelfHostedUser: true,
});
expect(state()).toBe(ChatSupportState.Unavailable);
});
});
describe('unavailable', () => {
it('offers nothing without the chat support flag', () => {
setup({ chatSupport: false });
expect(state()).toBe(ChatSupportState.Unavailable);
});
it('offers nothing when logged out', () => {
setup({ isLoggedIn: false });
expect(state()).toBe(ChatSupportState.Unavailable);
});
it('offers nothing while the flags are still loading', () => {
setup({ isFetchingFeatureFlags: true });
expect(state()).toBe(ChatSupportState.Unavailable);
});
it('offers nothing before the licence has loaded', () => {
setup({ activeLicense: null });
expect(state()).toBe(ChatSupportState.Unavailable);
});
it('offers nothing on a tenant that is neither cloud nor enterprise', () => {
setup({
trialConverted: true,
isCloudUser: false,
isEnterpriseSelfHostedUser: false,
});
expect(state()).toBe(ChatSupportState.Unavailable);
});
});
});

View File

@@ -1,70 +0,0 @@
import { FeatureKeys } from 'constants/features';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useAppContext } from 'providers/App/App';
import { useMemo } from 'react';
export enum ChatSupportState {
/** Pylon is configured for this user — hand off to the widget. */
Pylon = 'pylon',
/** Trial without a card — offer the Add Credit Card flow instead. */
NeedsCard = 'needsCard',
/** No support entry at all. */
Unavailable = 'unavailable',
}
export function useChatSupport(): ChatSupportState {
const {
featureFlags,
isFetchingFeatureFlags,
featureFlagsFetchError,
trialInfo,
isLoggedIn,
activeLicense,
} = useAppContext();
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
return useMemo(() => {
const isReady =
!isFetchingFeatureFlags &&
(featureFlags || featureFlagsFetchError) &&
activeLicense &&
trialInfo;
if (!isReady || !isLoggedIn) {
return ChatSupportState.Unavailable;
}
const flag = (name: FeatureKeys): boolean =>
featureFlags?.find((f) => f.name === name)?.active || false;
if (!flag(FeatureKeys.CHAT_SUPPORT)) {
return ChatSupportState.Unavailable;
}
const needsCard =
!flag(FeatureKeys.PREMIUM_SUPPORT) &&
!trialInfo?.trialConvertedToSubscription;
if (needsCard) {
// The credit card flow is cloud-only
return isCloudUser
? ChatSupportState.NeedsCard
: ChatSupportState.Unavailable;
}
const pylonConfigured = Boolean(
window.signozBootData?.settings?.pylon?.enabled,
);
return (isCloudUser || isEnterpriseSelfHostedUser) && pylonConfigured
? ChatSupportState.Pylon
: ChatSupportState.Unavailable;
}, [
activeLicense,
featureFlags,
featureFlagsFetchError,
isCloudUser,
isEnterpriseSelfHostedUser,
isFetchingFeatureFlags,
isLoggedIn,
trialInfo,
]);
}

View File

@@ -1,57 +0,0 @@
import { renderHook } from '@testing-library/react';
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
import { StripItemKind, StripTone } from 'container/BottomStrip/types';
import { useTraceDetailsStripInfo } from '../useTraceDetailsStripInfo';
describe('useTraceDetailsStripInfo', () => {
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('puts the span and error counts in the strip', () => {
renderHook(() =>
useTraceDetailsStripInfo({
totalSpansCount: 600,
totalErrorSpansCount: 4,
}),
);
expect(useBottomStripStore.getState().left).toMatchObject([
{ kind: StripItemKind.KeyValue, label: 'Spans', value: 600 },
{
kind: StripItemKind.KeyValue,
label: 'Errors',
value: 4,
tone: StripTone.Error,
},
]);
});
it('leaves the error item untoned when nothing failed', () => {
renderHook(() =>
useTraceDetailsStripInfo({
totalSpansCount: 600,
totalErrorSpansCount: 0,
}),
);
expect(useBottomStripStore.getState().left?.[1]).toMatchObject({
value: 0,
tone: StripTone.Default,
});
});
it('clears the strip when the page unmounts', () => {
const { unmount } = renderHook(() =>
useTraceDetailsStripInfo({
totalSpansCount: 600,
totalErrorSpansCount: 4,
}),
);
unmount();
expect(useBottomStripStore.getState().left).toBeNull();
});
});

View File

@@ -15,7 +15,6 @@ import { useDetailsPanel } from 'components/DetailsPanel';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { LOCALSTORAGE } from 'constants/localStorage';
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
import { useTraceDetailsStripInfo } from './useTraceDetailsStripInfo';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { ResizableBox } from 'periscope/components/ResizableBox';
@@ -145,10 +144,6 @@ function TraceDetailsV3(): JSX.Element {
const allSpans = traceData?.payload?.spans || [];
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
const totalErrorSpansCount = traceData?.payload?.totalErrorSpansCount || 0;
useTraceDetailsStripInfo({ totalSpansCount, totalErrorSpansCount });
const isFullDataLoaded =
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
@@ -446,10 +441,7 @@ function TraceDetailsV3(): JSX.Element {
})}
>
<TriangleAlert size={13} />
Errors:{' '}
{traceData.payload.totalErrorSpansCount ?? (
<span className="translate-safe">{0}</span>
)}
Errors: {traceData.payload.totalErrorSpansCount ?? 0}
</span>
</span>
) : null}

View File

@@ -1,43 +0,0 @@
import { useMemo } from 'react';
import { ChartNoAxesGantt, TriangleAlert } from '@signozhq/icons';
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
import {
type StripItem,
StripItemKind,
StripTone,
} from 'container/BottomStrip/types';
interface UseTraceDetailsStripInfoArgs {
totalSpansCount: number;
totalErrorSpansCount: number;
}
/**
* Takes the counts rather than fetching them: the trace query key includes the
* selected span, so a fetch here would fire on every span click.
*/
export function useTraceDetailsStripInfo({
totalSpansCount,
totalErrorSpansCount,
}: UseTraceDetailsStripInfoArgs): void {
const items = useMemo<StripItem[]>(
() => [
{
kind: StripItemKind.KeyValue,
label: 'Spans',
value: totalSpansCount,
prefix: <ChartNoAxesGantt size={13} />,
},
{
kind: StripItemKind.KeyValue,
label: 'Errors',
value: totalErrorSpansCount,
prefix: <TriangleAlert size={13} />,
tone: totalErrorSpansCount > 0 ? StripTone.Error : StripTone.Default,
},
],
[totalSpansCount, totalErrorSpansCount],
);
useBottomStrip(items);
}

View File

@@ -813,12 +813,6 @@ body.ai-assistant-panel-open {
}
}
body.bottom-strip-on {
.PylonChat-chatWindowFrameContainer {
bottom: var(--bottom-strip-height, 0px) !important;
}
}
[role='tab'] {
color: var(--foreground) !important;
}

View File

@@ -67,5 +67,23 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/traces/{traceID}/thread", handler.New(
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetThread),
handler.OpenAPIDef{
ID: "GetTraceThread",
Tags: []string{"tracedetail"},
Summary: "Get thread view for a trace",
Description: "Returns the spans carrying gen_ai input or output messages in timestamp order, each with the messages normalised into formatted_input and formatted_output. Pages are fetched with the returned nextCursor.",
RequestQuery: new(spantypes.QueryableThread),
Response: new(spantypes.GettableTraceThread),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -75,3 +75,25 @@ func (h *handler) GetFlamegraph(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, result)
}
func (h *handler) GetThread(rw http.ResponseWriter, r *http.Request) {
req := new(spantypes.QueryableThread)
if err := binding.Query.BindQuery(r.URL.Query(), req); err != nil {
render.Error(rw, err)
return
}
query, err := spantypes.NewThreadQuery(req)
if err != nil {
render.Error(rw, err)
return
}
result, err := h.module.GetThread(r.Context(), mux.Vars(r)["traceID"], query)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

View File

@@ -173,6 +173,19 @@ func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpan
), nil
}
func (m *module) GetThread(ctx context.Context, traceID string, query *spantypes.ThreadQuery) (*spantypes.GettableTraceThread, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
return nil, err
}
spans, err := m.store.GetThreadSpans(ctx, traceID, summary, query.Cursor, query.Limit+1)
if err != nil {
return nil, err
}
return spantypes.NewGettableTraceThread(traceID, spans, query.Limit), nil
}
func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary *spantypes.TraceSummary, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, summary.Start, summary.End, nil)
if err != nil {

View File

@@ -11,12 +11,23 @@ import (
"github.com/SigNoz/signoz/pkg/clickhousesql"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const colServiceName = `resource_string_service$$$$name` // $ gets escaped so $$$$ converts to $$.
var fullSpanColumns = []string{
"duration_nano", "span_id", "has_error", "kind",
colServiceName, "name",
"attributes_string", "attributes_number", "attributes_bool", "resources_string",
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
"flags", "is_remote", "trace_state", "status_code",
"db_name", "db_operation", "http_method", "http_url", "http_host",
"external_http_method", "external_http_url", "response_status_code", "links as references",
}
func buildFieldExpr(fieldKey telemetrytypes.TelemetryFieldKey) (string, error) {
switch fieldKey.FieldContext {
case telemetrytypes.FieldContextResource:
@@ -123,16 +134,8 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
return []spantypes.StorableSpan{}, nil
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"DISTINCT ON (span_id) timestamp",
"duration_nano", "span_id", "has_error", "kind",
colServiceName, "name",
"attributes_string", "attributes_number", "attributes_bool", "resources_string",
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
"flags", "is_remote", "trace_state", "status_code",
"db_name", "db_operation", "http_method", "http_url", "http_host",
"external_http_method", "external_http_url", "response_status_code", "links as references",
)
sb.Select("DISTINCT ON (span_id) timestamp")
sb.SelectMore(fullSpanColumns...)
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
ids := make([]any, len(spanIDs))
for i, id := range spanIDs {
@@ -155,6 +158,44 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
return spans, nil
}
func (s *traceStore) GetThreadSpans(ctx context.Context, traceID string, summary *spantypes.TraceSummary, cursor *spantypes.ThreadCursor, limit int) ([]spantypes.StorableSpan, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("DISTINCT ON (span_id) timestamp")
sb.SelectMore(fullSpanColumns...)
sb.SelectMore("attributes")
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
sb.Where(
sb.E("trace_id", traceID),
sb.GE("ts_bucket_start", summary.Start.Unix()-1800),
sb.LE("ts_bucket_start", summary.End.Unix()),
// Reads only the JSON column; spans with messages only in the legacy maps are skipped.
// todo(nitya): pick the column from the attribute evolution metadata.
sb.Or(
sqlbuilder.Escape(fmt.Sprintf("attributes.%s IS NOT NULL", clickhousesql.Identifier(aiobservabilitytypes.GenAIInputMessages))),
sqlbuilder.Escape(fmt.Sprintf("attributes.%s IS NOT NULL", clickhousesql.Identifier(aiobservabilitytypes.GenAIOutputMessages))),
),
)
if cursor != nil {
// ClickHouse can't use an index for a tuple comparison, so the separate timestamp and
// ts_bucket_start bounds are what skip the data before the cursor.
sb.Where(
sb.GE("ts_bucket_start", int64(cursor.TimeUnixNano/uint64(time.Second))-1800),
sb.GE("timestamp", fmt.Sprintf("%d", cursor.TimeUnixNano)),
sb.GT("(toUnixTimestamp64Nano(timestamp), span_id)", sqlbuilder.Tuple(cursor.TimeUnixNano, cursor.SpanID)),
)
}
sb.OrderByAsc("timestamp")
sb.OrderByAsc("span_id")
sb.Limit(limit)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var spans []spantypes.StorableSpan
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying thread spans")
}
return spans, nil
}
func (s *traceStore) GetFlamegraphSpans(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]spantypes.StorableSpan, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select(

View File

@@ -13,6 +13,7 @@ type Handler interface {
GetWaterfallV4(http.ResponseWriter, *http.Request)
GetTraceAggregations(http.ResponseWriter, *http.Request)
GetFlamegraph(http.ResponseWriter, *http.Request)
GetThread(http.ResponseWriter, *http.Request)
}
// Module defines the business logic for trace detail operations.
@@ -20,4 +21,5 @@ type Module interface {
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error)
GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error)
GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error)
GetThread(ctx context.Context, traceID string, query *spantypes.ThreadQuery) (*spantypes.GettableTraceThread, error)
}

View File

@@ -566,24 +566,6 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
}, nil
}
// flattenJSONPaths flattens a decoded JSON document into dotted keys, overwriting existing keys in out.
func flattenJSONPaths(prefix string, m map[string]any, out map[string]any) {
for k, v := range m {
key := k
if prefix != "" {
key = prefix + "." + k
}
switch child := v.(type) {
case map[string]any:
flattenJSONPaths(key, child, out)
case telemetrystoretypes.JSONValue:
flattenJSONPaths(key, child, out)
default:
out[key] = v
}
}
}
// mergeSpanAttributeColumns merges (attributes_string, attributes_number, attributes_bool, resources_string) into
// unified "attributes" and "resource" keys, and parses the stringified `events`
// and `links` columns into structured slices. Raw DB columns are removed.
@@ -598,7 +580,7 @@ func mergeSpanAttributeColumns(data map[string]any) {
resStr, hasRes := data["resources_string"]
if hasStr || hasNum || hasBool || attrJSON != nil || hasRes {
attributes := make(map[string]any)
flattenJSONPaths("", attrJSON, attributes)
attrJSON.FlattenInto("", attributes)
if m, ok := attrStr.(map[string]string); ok {
for k, v := range m {
attributes[k] = v

View File

@@ -13,7 +13,6 @@ import (
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
@@ -50,6 +49,11 @@ type rule struct {
OrgID string `bun:"org_id,type:text"`
}
type routePolicyRuleData struct {
PreferredChannels []string `json:"preferredChannels"`
Labels map[string]string `json:"labels"`
}
type addRoutePolicies struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
@@ -187,20 +191,20 @@ func (migration *addRoutePolicies) migrateRulesToRoutePolicies(ctx context.Conte
func (migration *addRoutePolicies) convertRulesToRoutes(rules []*rule, channelsByOrg map[string][]string) ([]*expressionRoute, error) {
var routes []*expressionRoute
for _, r := range rules {
var gettableRule ruletypes.GettableRule
if err := json.Unmarshal([]byte(r.Data), &gettableRule); err != nil {
var ruleData routePolicyRuleData
if err := json.Unmarshal([]byte(r.Data), &ruleData); err != nil {
return nil, errors.NewInternalf(errors.CodeInternal, "failed to unmarshal rule data for rule ID %s: %v", r.ID, err)
}
if len(gettableRule.PreferredChannels) == 0 {
if len(ruleData.PreferredChannels) == 0 {
channels, exists := channelsByOrg[r.OrgID]
if !exists || len(channels) == 0 {
continue
}
gettableRule.PreferredChannels = channels
ruleData.PreferredChannels = channels
}
severity := "critical"
if v, ok := gettableRule.Labels["severity"]; ok {
if v, ok := ruleData.Labels["severity"]; ok {
severity = v
}
expression := fmt.Sprintf(`%s == "%s" && %s == "%s"`, "threshold.name", severity, "ruleId", r.ID.String())
@@ -218,7 +222,7 @@ func (migration *addRoutePolicies) convertRulesToRoutes(rules []*rule, channelsB
},
Expression: expression,
ExpressionKind: "rule",
Channels: gettableRule.PreferredChannels,
Channels: ruleData.PreferredChannels,
Name: r.ID.StringValue(),
Enabled: true,
OrgID: r.OrgID,

View File

@@ -364,12 +364,26 @@ func (provider *provider) gc(ctx context.Context, org *types.Organization) error
}
func (provider *provider) flushLastObservedAt(ctx context.Context, org *types.Organization) error {
accessTokenToLastObservedAt, err := provider.listLastObservedAtDesc(ctx, org.ID)
tokens, err := provider.tokenStore.ListByOrgID(ctx, org.ID)
if err != nil {
return err
}
if err := provider.tokenStore.UpdateLastObservedAtByAccessToken(ctx, accessTokenToLastObservedAt); err != nil {
observedTokens := make([]*authtypes.StorableToken, 0, len(tokens))
for _, token := range tokens {
cachedLastObservedAt, ok := provider.lastObservedAtCache.Get(lastObservedAtCacheKey(token.AccessToken, token.UserID))
if !ok {
continue
}
if err := token.UpdateLastObservedAt(cachedLastObservedAt); err != nil {
continue
}
observedTokens = append(observedTokens, token)
}
if err := provider.tokenStore.UpdateLastObservedAt(ctx, observedTokens); err != nil {
return err
}

View File

@@ -232,15 +232,16 @@ func (store *store) ListByUserID(ctx context.Context, userID valuer.UUID) ([]*au
return tokens, nil
}
func (store *store) UpdateLastObservedAtByAccessToken(ctx context.Context, accessTokenToLastObservedAt []map[string]any) error {
if len(accessTokenToLastObservedAt) == 0 {
func (store *store) UpdateLastObservedAt(ctx context.Context, tokens []*authtypes.StorableToken) error {
if len(tokens) == 0 {
return nil
}
values := store.
sqlstore.
BunDBCtx(ctx).
NewValues(&accessTokenToLastObservedAt)
NewValues(&tokens).
Column("id", "last_observed_at", "updated_at")
_, err := store.
sqlstore.
@@ -250,8 +251,8 @@ func (store *store) UpdateLastObservedAtByAccessToken(ctx context.Context, acces
Model((*authtypes.StorableToken)(nil)).
TableExpr("update_cte").
Set("last_observed_at = update_cte.last_observed_at").
Where("auth_token.access_token = update_cte.access_token").
Where("auth_token.user_id = update_cte.user_id").
Set("updated_at = update_cte.updated_at").
Where("auth_token.id = update_cte.id").
Exec(ctx)
if err != nil {
return err

View File

@@ -0,0 +1,41 @@
package genaimessages
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testdata/captured/<sdk>/<attribute>.txt holds a real attribute value from the scripts
// repo's static-telemetry-generator captures; <attribute>.want.json is its reviewed output.
func TestNormalizeCapturedPayloads(t *testing.T) {
inputs, err := filepath.Glob(filepath.Join("testdata", "captured", "*", "*.txt"))
require.NoError(t, err)
require.NotEmpty(t, inputs)
for _, input := range inputs {
sdk := filepath.Base(filepath.Dir(input))
attribute := strings.TrimSuffix(filepath.Base(input), ".txt")
t.Run(sdk+"/"+attribute, func(t *testing.T) {
raw, err := os.ReadFile(input)
require.NoError(t, err)
wantJSON, err := os.ReadFile(strings.TrimSuffix(input, ".txt") + ".want.json")
require.NoError(t, err)
var want []aiobservabilitytypes.Message
require.NoError(t, json.Unmarshal(wantJSON, &want))
got, err := json.Marshal(Normalize(string(raw)))
require.NoError(t, err)
var gotRoundTrip []aiobservabilitytypes.Message
require.NoError(t, json.Unmarshal(got, &gotRoundTrip))
assert.Equal(t, want, gotRoundTrip)
})
}
}

View File

@@ -0,0 +1,307 @@
package genaimessages
import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
)
// convertSemconvMessages handles [{role, parts, finish_reason}] and Gemini contents.
func convertSemconvMessages(value any) ([]aiobservabilitytypes.Message, bool) {
list, ok := value.([]any)
if !ok || len(list) == 0 {
return nil, false
}
first, ok := toObject(firstItem(list))
if !ok {
return nil, false
}
if ok := first.has("parts"); !ok {
return nil, false
}
messages := make([]aiobservabilitytypes.Message, 0, len(list))
for _, item := range list {
m, ok := toObject(item)
if !ok {
messages = append(messages, genericMessages(stringOf(item))...)
continue
}
messages = append(messages, semconvMessage(m, "")...)
}
return messages, true
}
// convertChatMessageList handles OpenAI, Anthropic, Vercel and LangChain message lists.
func convertChatMessageList(value any) ([]aiobservabilitytypes.Message, bool) {
list, ok := value.([]any)
if !ok || len(list) == 0 {
return nil, false
}
if !isChatMessage(firstItem(list)) {
return nil, false
}
messages := make([]aiobservabilitytypes.Message, 0, len(list))
for _, item := range list {
messages = append(messages, chatMessage(item, "")...)
}
return messages, true
}
func isChatMessage(value any) bool {
m, ok := toObject(value)
if !ok {
return false
}
if ok := m.has("role"); ok {
return true
}
switch typ := m.str("type"); typ {
case "message", "reasoning", "human", "ai", "tool", "system":
return true
case "constructor":
ok := m.has("kwargs")
return ok
default:
return isResponsesItemType(typ)
}
}
// convertToolCallList handles a bare tool call list, e.g. Vercel ai.response.toolCalls.
func convertToolCallList(value any) ([]aiobservabilitytypes.Message, bool) {
list, ok := value.([]any)
if !ok || len(list) == 0 {
return nil, false
}
msg := aiobservabilitytypes.Message{Role: aiobservabilitytypes.MessageRoleAssistant, Content: make([]aiobservabilitytypes.Part, 0, len(list))}
for _, item := range list {
call, ok := toObject(item)
if !ok || !isToolCall(call) {
return nil, false
}
msg.Content = append(msg.Content, toolCallPart(call))
}
return []aiobservabilitytypes.Message{msg}, true
}
// isToolCall rejects tool definitions, which carry no arguments.
func isToolCall(call object) bool {
if has := call.has("toolName"); has {
return true
}
if fn, ok := call.obj("function"); ok {
has := fn.has("arguments")
return has
}
if has := call.has("name"); !has {
return false
}
_, has := call.lookup("arguments", "args")
return has
}
// convertChatRequest handles OpenAI, Anthropic, Vercel, Gemini and LangChain request objects.
func convertChatRequest(value any) ([]aiobservabilitytypes.Message, bool) {
m, ok := toObject(value)
if !ok {
return nil, false
}
conversation, ok := m.lookup("messages", "input", "contents", "prompt")
if !ok {
return nil, false
}
messages := []aiobservabilitytypes.Message{}
if system := systemMessage(m.first("system", "instructions", "system_instruction", "systemInstruction", "system_prompt")); system != nil {
messages = append(messages, *system)
} else if config, ok := m.obj("config"); ok {
if system := systemMessage(config.first("system_instruction", "systemInstruction")); system != nil {
messages = append(messages, *system)
}
}
// {messages: "[...]"}: the list arrives JSON-encoded once more from some SDKs
if s, isString := conversation.(string); isString {
var decoded any
if err := json.Unmarshal([]byte(s), &decoded); err == nil {
if _, isList := decoded.([]any); isList {
conversation = decoded
}
}
}
switch c := conversation.(type) {
case string:
if c != "" {
messages = append(messages, textMessage(aiobservabilitytypes.MessageRoleUser, c))
}
case []any:
for _, item := range flattenOnce(c) {
if s, isString := item.(string); isString {
messages = append(messages, textMessage(aiobservabilitytypes.MessageRoleUser, s))
continue
}
messages = append(messages, semconvMessage(asObject(item), aiobservabilitytypes.MessageRoleUser)...)
}
case map[string]any:
messages = append(messages, semconvMessage(c, aiobservabilitytypes.MessageRoleUser)...)
default:
return nil, false
}
return messages, true
}
// systemMessage returns nil when value is empty or unknown.
func systemMessage(value any) *aiobservabilitytypes.Message {
msg := aiobservabilitytypes.Message{Role: aiobservabilitytypes.MessageRoleSystem, Content: []aiobservabilitytypes.Part{}}
switch v := value.(type) {
case string:
if v == "" {
return nil
}
msg.Content = append(msg.Content, textPart(v))
case []any:
for _, item := range v {
msg.Content = append(msg.Content, contentBlockPart(item))
}
case map[string]any:
parts, ok := object(v).list("parts")
if !ok {
return nil
}
for _, p := range parts {
msg.Content = append(msg.Content, semconvPart(p))
}
default:
return nil
}
if len(msg.Content) == 0 {
return nil
}
return &msg
}
// convertChatResponse handles the OpenAI Chat Completions {choices} response.
func convertChatResponse(value any) ([]aiobservabilitytypes.Message, bool) {
m, ok := toObject(value)
if !ok {
return nil, false
}
choices, ok := m.list("choices")
if !ok {
return nil, false
}
messages := make([]aiobservabilitytypes.Message, 0, len(choices))
for _, c := range choices {
choice := asObject(c)
var converted []aiobservabilitytypes.Message
if message, ok := toObject(choice.first("message", "delta")); ok {
converted = chatMessage(message, aiobservabilitytypes.MessageRoleAssistant)
} else if text, ok := choice.get("text"); ok {
converted = []aiobservabilitytypes.Message{textMessage(aiobservabilitytypes.MessageRoleAssistant, stringOf(text))}
} else {
converted = []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleAssistant, Content: []aiobservabilitytypes.Part{genericPart(choice)}}}
}
messages = append(messages, withFinishReason(converted, choice.str("finish_reason"))...)
}
return messages, true
}
func convertResponsesAPIResponse(value any) ([]aiobservabilitytypes.Message, bool) {
m, ok := toObject(value)
if !ok {
return nil, false
}
output, ok := m.list("output")
if !ok {
return nil, false
}
messages := make([]aiobservabilitytypes.Message, 0, len(output))
for _, item := range output {
messages = append(messages, chatMessage(item, aiobservabilitytypes.MessageRoleAssistant)...)
}
if last := lastMessage(messages); last != nil && last.FinishReason == "" {
if details, ok := m.obj("incomplete_details"); ok {
last.FinishReason = normalizeFinishReason(details.str("reason"))
} else if m.str("status") == "completed" {
last.FinishReason = aiobservabilitytypes.FinishReasonStop
}
}
return messages, true
}
// convertGeminiResponse handles Gemini {candidates} and Google ADK {content} responses.
func convertGeminiResponse(value any) ([]aiobservabilitytypes.Message, bool) {
m, ok := toObject(value)
if !ok {
return nil, false
}
if content, ok := m.obj("content"); ok {
if hasParts := content.has("parts"); hasParts {
return withFinishReason(semconvMessage(content, aiobservabilitytypes.MessageRoleAssistant), finishReasonOf(m)), true
}
}
candidates, ok := m.list("candidates")
if !ok {
return nil, false
}
messages := make([]aiobservabilitytypes.Message, 0, len(candidates))
for _, c := range candidates {
candidate := asObject(c)
content, ok := candidate.obj("content")
if !ok {
generic := []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleAssistant, Content: []aiobservabilitytypes.Part{genericPart(candidate)}}}
messages = append(messages, withFinishReason(generic, finishReasonOf(candidate))...)
continue
}
messages = append(messages, withFinishReason(semconvMessage(content, aiobservabilitytypes.MessageRoleAssistant), finishReasonOf(candidate))...)
}
return messages, true
}
func convertLegacyCompletion(value any) ([]aiobservabilitytypes.Message, bool) {
m, ok := toObject(value)
if !ok {
return nil, false
}
completion, ok := m.text("completion")
if !ok {
return nil, false
}
msg := aiobservabilitytypes.Message{Role: aiobservabilitytypes.MessageRoleAssistant, Content: []aiobservabilitytypes.Part{}}
if reasoning, ok := m.text("reasoning"); ok && reasoning != "" {
msg.Content = append(msg.Content, aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: reasoning})
}
msg.Content = append(msg.Content, textPart(completion))
return []aiobservabilitytypes.Message{msg}, true
}
func convertLangChainGenerations(value any) ([]aiobservabilitytypes.Message, bool) {
m, ok := toObject(value)
if !ok {
return nil, false
}
generations, ok := m.list("generations")
if !ok {
return nil, false
}
messages := []aiobservabilitytypes.Message{}
for _, g := range flattenOnce(generations) {
gen := asObject(g)
var converted []aiobservabilitytypes.Message
if message, ok := gen.obj("message"); ok {
converted = chatMessage(message, aiobservabilitytypes.MessageRoleAssistant)
} else {
converted = []aiobservabilitytypes.Message{textMessage(aiobservabilitytypes.MessageRoleAssistant, gen.str("text"))}
}
messages = append(messages, withFinishReason(converted, asObject(gen.at("generation_info")).str("finish_reason"))...)
}
return messages, true
}
// withFinishReason sets reason on the last message that has none.
func withFinishReason(messages []aiobservabilitytypes.Message, reason string) []aiobservabilitytypes.Message {
if last := lastMessage(messages); last != nil && last.FinishReason == "" {
last.FinishReason = normalizeFinishReason(reason)
}
return messages
}

View File

@@ -0,0 +1,288 @@
package genaimessages
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
)
func TestNormalizeEnvelopeRequests(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "AnthropicRequest_SystemStringBecomesSystemMessage",
raw: `{
"model": "claude",
"system": "Be brief.",
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 100
}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleSystem,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Be brief."},
},
},
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
},
},
},
},
{
name: "GeminiRequest_ConfigSystemInstructionBecomesSystemMessage",
raw: `{
"model": "gemini-2.0",
"config": {"system_instruction": "Be brief."},
"contents": [
{"role": "user", "parts": [{"text": "Hi"}]},
{
"role": "user",
"parts": [
{
"function_response": {"name": "get_weather", "response": {"temp": 20}}
}
]
}
]
}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleSystem,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Be brief."},
},
},
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
},
},
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolResult, Name: "get_weather", Content: `{"temp":20}`},
},
},
},
},
{
name: "GeminiRequest_SystemInstructionPartsBecomeSystemMessage",
raw: `{"system_instruction": {"parts": [{"text": "Be brief."}]}, "contents": [{"role": "user", "parts": [{"text": "Hi"}]}]}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleSystem,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Be brief."},
},
},
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
},
},
},
},
{
name: "RequestWithMessagesAsJSONString_MessagesDecoded",
raw: `{"messages":"[{\"role\":\"user\",\"content\":\"Hi\"}]"}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
},
},
},
},
})
}
func TestNormalizeEnvelopeChatResponses(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "StreamingChunkDelta_BecomesAssistantText",
raw: `{"choices": [{"delta": {"role": "assistant", "content": "Hel"}, "finish_reason": null}]}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hel"},
},
},
},
},
{
name: "LegacyCompletionChoiceText_BecomesAssistantTextWithLength",
raw: `{"choices": [{"text": "Hello", "finish_reason": "length"}]}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
FinishReason: aiobservabilitytypes.FinishReasonLength,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hello"},
},
},
},
},
{
name: "FilteredChoiceWithoutMessage_BecomesGenericWithContentFilter",
raw: `{"choices": [{"index": 0, "finish_reason": "content_filter"}]}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
FinishReason: aiobservabilitytypes.FinishReasonContentFilter,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeGeneric, Content: `{"finish_reason":"content_filter","index":0}`},
},
},
},
},
})
}
func TestNormalizeEnvelopeOpenAIResponses(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "ResponsesAPIResponse_MessageAndReasoningItemsConverted",
raw: `{
"object": "response",
"status": "completed",
"output": [
{
"type": "reasoning",
"id": "rs_1",
"summary": [{"type": "summary_text", "text": "Thinking about it"}]
},
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Paris."}]
}
]
}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeThinking, Content: "Thinking about it"},
},
},
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Paris."},
},
FinishReason: aiobservabilitytypes.FinishReasonStop,
},
},
},
{
name: "ResponsesAPIResponseIncomplete_EncryptedReasoningEmptyAndFinishReasonLength",
raw: `{
"output": [
{"type": "reasoning", "content": [{"type": "reasoning_text", "text": "step 1"}]},
{"type": "reasoning", "encrypted_content": "gAAAA"}
],
"status": "incomplete",
"incomplete_details": {"reason": "max_output_tokens"}
}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeThinking, Content: "step 1"},
},
},
{
Role: aiobservabilitytypes.MessageRoleAssistant,
FinishReason: aiobservabilitytypes.FinishReasonLength,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeThinking},
},
},
},
},
})
}
func TestNormalizeEnvelopeGemini(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "GeminiResponse_ThoughtAndFunctionCallPartsConverted",
raw: `{
"candidates": [
{
"content": {
"parts": [
{"text": "Let me check", "thought": true},
{"function_call": {"name": "get_weather", "args": {"city": "Paris"}}}
],
"role": "model"
},
"finishReason": "STOP"
}
],
"usageMetadata": {}
}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
FinishReason: aiobservabilitytypes.FinishReasonStop,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeThinking, Content: "Let me check"},
{Type: aiobservabilitytypes.PartTypeToolCall, Name: "get_weather", Arguments: map[string]any{"city": "Paris"}},
},
},
},
},
{
name: "GoogleADKResponse_ContentBecomesAssistantMessageWithStop",
raw: `{"content": {"parts": [{"text": "It is 18°C and clear in Bengaluru."}], "role": "model"}, "finish_reason": "STOP"}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
FinishReason: aiobservabilitytypes.FinishReasonStop,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "It is 18°C and clear in Bengaluru."},
},
},
},
},
{
name: "GeminiResponseBlockedBySafety_BecomesGenericWithContentFilter",
raw: `{"candidates": [{"finishReason": "SAFETY", "index": 0}]}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
FinishReason: aiobservabilitytypes.FinishReasonContentFilter,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeGeneric, Content: `{"finishReason":"SAFETY","index":0}`},
},
},
},
},
})
}
func TestNormalizeEnvelopeLangChain(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "LangChainGenerationWithOnlyText_BecomesAssistantTextWithLength",
raw: `{"generations": [[{"text": "Observability is the ability", "generation_info": {"finish_reason": "length"}}]]}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
FinishReason: aiobservabilitytypes.FinishReasonLength,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Observability is the ability"},
},
},
},
},
})
}

View File

@@ -0,0 +1,71 @@
package genaimessages
import (
"encoding/json"
"strings"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
)
// Ordered by specificity: earlier converters never match a later format.
var converters = []converter{
convertSemconvMessages,
convertChatMessageList,
convertToolCallList,
convertChatRequest,
convertChatResponse,
convertResponsesAPIResponse,
convertGeminiResponse,
convertLegacyCompletion,
convertLangChainGenerations,
}
// Normalize converts a gen_ai.*.messages value, a JSON string or a decoded value.
// A string that is not JSON becomes one text part; JSON in no known format becomes
// one generic part holding the original.
func Normalize(raw any) []aiobservabilitytypes.Message {
var (
value any
original string
)
switch v := raw.(type) {
case nil:
return []aiobservabilitytypes.Message{}
case string:
if strings.TrimSpace(v) == "" {
return []aiobservabilitytypes.Message{}
}
original = v
if err := json.Unmarshal([]byte(v), &value); err != nil {
return []aiobservabilitytypes.Message{{Content: []aiobservabilitytypes.Part{textPart(v)}}}
}
default:
value = v
original = stringOf(v)
}
if value == nil {
return []aiobservabilitytypes.Message{}
}
if list, ok := value.([]any); ok {
if len(list) == 0 {
return []aiobservabilitytypes.Message{}
}
// [[...]]: some SDKs wrap the conversation in one more list
if _, nested := firstItem(list).([]any); nested {
value = flattenOnce(list)
}
// ["{...}", "{...}"]: an array attribute holding one JSON message per element
if decoded, ok := decodeJSONStrings(list); ok {
value = decoded
}
}
for _, convert := range converters {
if messages, ok := convert(value); ok {
return messages
}
}
return genericMessages(original)
}
type converter func(value any) (messages []aiobservabilitytypes.Message, ok bool)

View File

@@ -0,0 +1,146 @@
package genaimessages
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/stretchr/testify/assert"
)
func TestNormalize(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "ListNestedInAnotherList_Unwrapped",
raw: `[[{"role":"user","content":"Hi"}]]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
},
},
},
},
{
name: "ListOfToolDefinitions_FallsBackToGeneric",
raw: `[{"type":"function","function":{"name":"get_weather","description":"Weather","parameters":{"type":"object"}}}]`,
want: []aiobservabilitytypes.Message{
{
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeGeneric, Content: `[{"type":"function","function":{"name":"get_weather","description":"Weather","parameters":{"type":"object"}}}]`},
},
},
},
},
{
name: "ListOfMessagesAsJSONStrings_EachDecoded",
raw: []any{`{"role":"user","content":"Hi"}`, `{"role":"assistant","content":"Hello"}`},
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
},
},
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hello"},
},
},
},
},
{
name: "TextThatIsNotJSON_ReturnedAsOneTextPart",
raw: "Let the cost of the ball be x dollars.",
want: []aiobservabilitytypes.Message{
{
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Let the cost of the ball be x dollars."},
},
},
},
},
{
name: "JSONInNoKnownFormat_ReturnedVerbatimAsGeneric",
raw: `{"output": "{\"query\": \"SigNoz\"}", "kwargs": {"name": "search_web"}}`,
want: []aiobservabilitytypes.Message{
{
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeGeneric, Content: `{"output": "{\"query\": \"SigNoz\"}", "kwargs": {"name": "search_web"}}`},
},
},
},
},
{
name: "AlreadyDecodedValue_ConvertedLikeJSON",
raw: []any{map[string]any{"role": "user", "content": "hi"}},
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "hi"},
},
},
},
},
{
name: "BlankString_ReturnsNoMessages",
raw: " \n\t ",
want: []aiobservabilitytypes.Message{},
},
{
name: "JSONNull_ReturnsNoMessages",
raw: `null`,
want: []aiobservabilitytypes.Message{},
},
})
}
func FuzzNormalize(f *testing.F) {
for _, seed := range []string{
`[{"role": "user", "parts": [{"type": "text", "content": "Hi"}]}]`,
`[{"role": "assistant", "content": [{"type": "tool_use", "id": "toolu_1", "name": "f", "input": {}}]}]`,
`[{"role": "tool", "tool_call_id": "call_1", "content": [{"type": "text", "text": "ok"}]}]`,
`{"messages": [{"role": "user", "content": "Hi"}], "system": [{"type": "text", "text": "Be brief."}]}`,
`{"choices": [{"message": {"role": "assistant", "content": "Hi"}, "finish_reason": "stop"}]}`,
`{"output": [{"type": "function_call", "call_id": "c", "name": "f", "arguments": "{}"}, {"type": "reasoning"}]}`,
`{"candidates": [{"content": {"parts": [{"functionCall": {"name": "f", "args": {}}}], "role": "model"}}]}`,
`{"generations": [[{"text": "Hi", "message": {"lc": 1, "type": "constructor", "id": ["AIMessage"], "kwargs": {}}}]]}`,
`[[{"role": "user", "content": "Hi"}]]`,
`["{\"role\": \"user\", \"content\": \"Hi\"}"]`,
``, `null`, `{}`, `[]`, `[{}]`, `"text"`, `42`,
} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, raw string) {
inputs := []any{raw}
var decoded any
if json.Unmarshal([]byte(raw), &decoded) == nil {
inputs = append(inputs, decoded)
}
for _, input := range inputs {
for i, message := range Normalize(input) {
if message.Content == nil {
t.Fatalf("message %d has nil content for %#v", i, input)
}
}
}
})
}
type normalizeCase struct {
name string
raw any
want []aiobservabilitytypes.Message
}
func assertNormalize(t *testing.T, testCases []normalizeCase) {
t.Helper()
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, Normalize(testCase.raw))
})
}
}

View File

@@ -0,0 +1,254 @@
package genaimessages
import (
"cmp"
"strings"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
)
// chatMessage returns nil for LangGraph tool definitions.
func chatMessage(value any, defaultRole aiobservabilitytypes.MessageRole) []aiobservabilitytypes.Message {
m, ok := toObject(value)
if !ok {
return genericMessages(stringOf(value))
}
if inner, role, ok := langChainMessage(m); ok {
return chatMessage(inner, role)
}
if isLangGraphToolDefinition(m) {
return nil
}
typ := m.str("type")
if isResponsesItemType(typ) {
return responsesItemMessages(m, typ)
}
if typ == "reasoning" {
return []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleAssistant, Content: reasoningParts(m)}}
}
role := cmp.Or(normalizeRole(m.str("role")), standardRole(typ), defaultRole)
msg := aiobservabilitytypes.Message{
Role: role,
Content: append(messageContentParts(m, role), messageToolCallParts(m)...),
FinishReason: normalizeFinishReason(finishReasonOf(m)),
}
if refusal := m.str("refusal"); refusal != "" {
msg.Content = append(msg.Content, textPart(refusal))
}
return []aiobservabilitytypes.Message{msg}
}
// langChainMessage unwraps a serialised message: {lc, type: "constructor", id: [..., "HumanMessage"], kwargs}.
func langChainMessage(m object) (object, aiobservabilitytypes.MessageRole, bool) {
kwargs, ok := m.obj("kwargs")
if !ok || m.str("type") != "constructor" {
return nil, "", false
}
return kwargs, cmp.Or(langChainRole(m.at("id")), standardRole(kwargs.str("type"))), true
}
func langChainRole(id any) aiobservabilitytypes.MessageRole {
path, ok := id.([]any)
if !ok || len(path) == 0 {
return ""
}
switch class := stringOf(lastItem(path)); {
case strings.HasPrefix(class, "System"):
return aiobservabilitytypes.MessageRoleSystem
case strings.HasPrefix(class, "Human"):
return aiobservabilitytypes.MessageRoleUser
case strings.HasPrefix(class, "AI"):
return aiobservabilitytypes.MessageRoleAssistant
case strings.HasPrefix(class, "Tool"), strings.HasPrefix(class, "Function"):
return aiobservabilitytypes.MessageRoleTool
}
return ""
}
// isLangGraphToolDefinition matches {role: "tool", content: {type: "function"}} without tool_call_id.
func isLangGraphToolDefinition(m object) bool {
if normalizeRole(m.str("role")) != aiobservabilitytypes.MessageRoleTool {
return false
}
if has := m.has("tool_call_id"); has {
return false
}
content, ok := m.obj("content")
if !ok || content.str("type") != "function" {
return false
}
ok = content.has("function")
return ok
}
// messageContentParts turns a tool message's text into its result.
func messageContentParts(m object, role aiobservabilitytypes.MessageRole) []aiobservabilitytypes.Part {
parts := []aiobservabilitytypes.Part{}
switch content := m.at("content").(type) {
case nil:
case string:
if role == aiobservabilitytypes.MessageRoleTool {
parts = append(parts, toolMessageResult(m, content))
} else if content != "" {
parts = append(parts, textPart(content))
}
case []any:
for _, item := range content {
part := contentBlockPart(item)
if role == aiobservabilitytypes.MessageRoleTool && part.Type == aiobservabilitytypes.PartTypeText {
part = toolMessageResult(m, part.Content)
}
parts = append(parts, part)
}
case map[string]any:
if contentParts, ok := object(content).list("parts"); ok {
for _, p := range contentParts {
parts = append(parts, semconvPart(p))
}
} else if role == aiobservabilitytypes.MessageRoleTool {
parts = append(parts, toolMessageResult(m, stringOf(content)))
} else {
parts = append(parts, genericPart(content))
}
default:
parts = append(parts, genericPart(content))
}
return parts
}
func messageToolCallParts(m object) []aiobservabilitytypes.Part {
calls, _ := m.first("tool_calls", "toolCalls").([]any)
if kwargs, ok := m.obj("additional_kwargs"); ok && len(calls) == 0 {
calls, _ = kwargs.list("tool_calls")
}
parts := make([]aiobservabilitytypes.Part, 0, len(calls)+1)
for _, call := range calls {
parts = append(parts, toolCallPart(call))
}
if call, ok := m.obj("function_call"); ok {
parts = append(parts, aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeToolCall, Name: call.str("name"), Arguments: parseArguments(call.at("arguments"))})
}
return parts
}
func toolMessageResult(m object, content string) aiobservabilitytypes.Part {
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: stringOf(m.first("tool_call_id", "toolCallId")), Name: m.str("name"), Content: content}
}
// semconvMessage falls back to chatMessage when m has no parts. LiteLLM mixes in
// OpenAI fields: tool_calls beside parts, and tool_call_id on tool messages.
func semconvMessage(m object, defaultRole aiobservabilitytypes.MessageRole) []aiobservabilitytypes.Message {
parts, ok := m.list("parts")
if !ok {
return chatMessage(m, defaultRole)
}
role := cmp.Or(normalizeRole(m.str("role")), defaultRole)
msg := aiobservabilitytypes.Message{
Role: role,
Content: []aiobservabilitytypes.Part{},
FinishReason: normalizeFinishReason(finishReasonOf(m)),
}
for _, p := range parts {
part := semconvPart(p)
if role == aiobservabilitytypes.MessageRoleTool && part.Type == aiobservabilitytypes.PartTypeText {
part = toolMessageResult(m, part.Content)
}
msg.Content = append(msg.Content, part)
}
msg.Content = append(msg.Content, messageToolCallParts(m)...)
return []aiobservabilitytypes.Message{msg}
}
// isResponsesItemType matches role-less Responses API tool and MCP items.
func isResponsesItemType(typ string) bool {
switch typ {
case "":
return false
case "function_call", "function_call_output", "tool_call", "custom_tool_call", "custom_tool_call_output",
"mcp_call", "mcp_list_tools", "mcp_approval_request", "mcp_approval_response":
return true
}
return strings.HasSuffix(typ, "_call") || strings.HasSuffix(typ, "_call_output")
}
// responsesItemMessages turns a built-in tool item into a call, plus its result when the item carries one.
func responsesItemMessages(m object, typ string) []aiobservabilitytypes.Message {
switch typ {
case "function_call", "tool_call", "custom_tool_call":
return []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleAssistant, Content: []aiobservabilitytypes.Part{{
Type: aiobservabilitytypes.PartTypeToolCall,
ID: stringOf(m.first("call_id", "id")),
Name: m.str("name"),
Arguments: parseArguments(m.first("arguments", "args", "input")),
}}}}
case "function_call_output", "custom_tool_call_output":
return []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleTool, Content: []aiobservabilitytypes.Part{{
Type: aiobservabilitytypes.PartTypeToolResult,
ToolCallID: stringOf(m.first("call_id", "id")),
Content: stringOf(m.first("output", "result")),
}}}}
}
id := stringOf(m.first("call_id", "id"))
if strings.HasSuffix(typ, "_output") || typ == "mcp_approval_response" {
return []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleTool, Content: []aiobservabilitytypes.Part{{
Type: aiobservabilitytypes.PartTypeToolResult,
ToolCallID: id,
Name: strings.TrimSuffix(typ, "_output"),
Content: stringOf(m.first("output", "result", "results")),
}}}}
}
args := make(map[string]any, len(m))
for k, v := range m {
switch k {
case "type", "id", "call_id", "status", "name", "server_label", "output", "result", "results":
default:
args[k] = v
}
}
name := stringOf(m.first("name", "server_label"))
if name == "" {
name = typ
}
call := aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeToolCall, ID: id, Name: name}
if arguments, ok := m.get("arguments"); ok {
call.Arguments = parseArguments(arguments)
} else if len(args) > 0 {
call.Arguments = args
}
msg := aiobservabilitytypes.Message{Role: aiobservabilitytypes.MessageRoleAssistant, Content: []aiobservabilitytypes.Part{call}}
if result := m.first("output", "result", "results"); result != nil {
msg.Content = append(msg.Content, aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: id, Name: name, Content: stringOf(result)})
}
return []aiobservabilitytypes.Message{msg}
}
// reasoningParts returns one empty thinking part for encrypted reasoning without a summary.
func reasoningParts(m object) []aiobservabilitytypes.Part {
parts := []aiobservabilitytypes.Part{}
if summary, ok := m.list("summary"); ok {
for _, s := range summary {
parts = append(parts, aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: stringOf(asObject(s).first("text", "content"))})
}
}
if content, ok := m.list("content"); ok {
for _, c := range content {
parts = append(parts, aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: stringOf(asObject(c).first("text", "content"))})
}
}
if len(parts) == 0 {
parts = append(parts, aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking})
}
return parts
}
func textMessage(role aiobservabilitytypes.MessageRole, content string) aiobservabilitytypes.Message {
return aiobservabilitytypes.Message{Role: role, Content: []aiobservabilitytypes.Part{textPart(content)}}
}
func genericMessages(content string) []aiobservabilitytypes.Message {
return []aiobservabilitytypes.Message{{Content: []aiobservabilitytypes.Part{genericPart(content)}}}
}

View File

@@ -0,0 +1,257 @@
package genaimessages
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
)
func TestNormalizeMessageChat(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "AnthropicMessages_ThinkingAndToolUseBlocksConverted",
raw: `[
{"role": "user", "content": "Hi"},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Let me see"},
{"type": "redacted_thinking", "data": "x"},
{
"type": "tool_use",
"id": "toolu_1",
"name": "lookup",
"input": {"q": "a"}
}
],
"stop_reason": "tool_use"
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_1",
"content": "found",
"is_error": true
}
]
}
]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
},
},
{
Role: aiobservabilitytypes.MessageRoleAssistant,
FinishReason: aiobservabilitytypes.FinishReasonToolCall,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeThinking, Content: "Let me see"},
{Type: aiobservabilitytypes.PartTypeThinking},
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "toolu_1", Name: "lookup", Arguments: map[string]any{"q": "a"}},
},
},
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "toolu_1", Content: "found", IsError: true},
},
},
},
},
{
name: "OpenAIToolMessageTextBlocks_BecomeToolResult",
raw: `[
{
"role": "tool",
"tool_call_id": "c1",
"content": [{"type": "text", "text": "20C"}, {"type": "text", "text": "clear"}]
}
]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleTool,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "c1", Content: "20C"},
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "c1", Content: "clear"},
},
},
},
},
{
name: "OpenAIRefusal_BecomesAssistantText",
raw: `[{"role": "assistant", "content": null, "refusal": "I can't help with that."}]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "I can't help with that."},
},
},
},
},
{
name: "OpenAILegacyFunctionCall_BecomesToolCall",
raw: `[{"role": "assistant", "content": null, "function_call": {"name": "get_weather", "arguments": "{\"city\": \"Paris\"}"}}]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolCall, Name: "get_weather", Arguments: map[string]any{"city": "Paris"}},
},
},
},
},
})
}
func TestNormalizeMessageLangChain(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "LangChainTypedMessages_ToolCallsReadFromAdditionalKwargs",
raw: `[
{"type": "human", "content": "Weather?"},
{
"type": "ai",
"content": "",
"additional_kwargs": {
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}
}
]
}
},
{
"type": "tool",
"content": "20C",
"tool_call_id": "call_1",
"name": "get_weather"
}
]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Weather?"},
},
},
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "call_1", Name: "get_weather", Arguments: map[string]any{"city": "Paris"}},
},
},
{
Role: aiobservabilitytypes.MessageRoleTool,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "call_1", Name: "get_weather", Content: "20C"},
},
},
},
},
{
name: "LangGraphToolDefinitionMessage_IsDropped",
raw: `[
{
"role": "tool",
"content": {
"type": "function",
"function": {"name": "get_weather", "parameters": {}}
}
},
{"role": "user", "content": "Hi"}
]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
},
},
},
},
})
}
func TestNormalizeMessageOpenAIResponsesItems(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "BuiltInToolCallItem_BecomesToolCall",
raw: `{
"object": "response",
"status": "completed",
"output": [
{
"type": "web_search_call",
"id": "ws_1",
"status": "completed",
"action": {"type": "search", "query": "SigNoz"}
},
{
"type": "custom_tool_call",
"call_id": "c1",
"name": "grep",
"input": "foo"
},
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Found it."}]
}
]
}`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "ws_1", Name: "web_search_call", Arguments: map[string]any{"action": map[string]any{"type": "search", "query": "SigNoz"}}},
},
},
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "c1", Name: "grep", Arguments: "foo"},
},
},
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeText, Content: "Found it."},
},
FinishReason: aiobservabilitytypes.FinishReasonStop,
},
},
},
{
name: "BuiltInToolOutputItem_BecomesToolResult",
raw: `[{"type": "local_shell_call_output", "call_id": "call_2", "output": "ok"}]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleTool,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "call_2", Name: "local_shell_call", Content: "ok"},
},
},
},
},
{
name: "MCPCallItemWithOutput_BecomesToolCallAndToolResult",
raw: `[{"type": "mcp_call", "id": "mcp_1", "server_label": "deepwiki", "name": "ask_question", "arguments": "{\"q\": \"What is SigNoz?\"}", "output": "An observability platform."}]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "mcp_1", Name: "ask_question", Arguments: map[string]any{"q": "What is SigNoz?"}},
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "mcp_1", Name: "ask_question", Content: "An observability platform."},
},
},
},
},
})
}

View File

@@ -0,0 +1,152 @@
package genaimessages
import (
"strings"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
)
func contentBlockPart(value any) aiobservabilitytypes.Part {
p, ok := toObject(value)
if !ok {
if s, ok := value.(string); ok {
return textPart(s)
}
return genericPart(value)
}
typ := p.str("type")
switch typ {
case "text", "input_text", "output_text", "refusal", "summary_text":
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeText, Content: stringOf(p.first("text", "content", "refusal"))}
case "thinking", "reasoning":
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: stringOf(p.first("thinking", "text", "content", "reasoning"))}
case "redacted_thinking":
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking}
case "tool-call", "tool_use", "tool_call", "function_call", "server_tool_use", "mcp_tool_use":
return toolCallPart(p)
case "tool-result", "tool_result", "function_call_output":
return toolResultBlockPart(p, p.first("result", "output", "content"))
case "":
if text, ok := p.get("text"); ok {
return textPart(stringOf(text))
}
default:
if strings.HasSuffix(typ, "_tool_result") {
return toolResultBlockPart(p, p.at("content"))
}
}
return genericPart(p)
}
func semconvPart(value any) aiobservabilitytypes.Part {
p, ok := toObject(value)
if !ok {
if s, ok := value.(string); ok {
return textPart(s)
}
return genericPart(value)
}
switch p.str("type") {
case "text":
if p.flag("thought") {
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: stringOf(p.first("content", "text"))}
}
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeText, Content: stringOf(p.first("content", "text"))}
case "reasoning", "thinking":
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: stringOf(p.first("content", "thinking", "text"))}
case "redacted_thinking", "redacted_reasoning":
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking}
case "tool_call":
return aiobservabilitytypes.Part{
Type: aiobservabilitytypes.PartTypeToolCall,
ID: p.str("id"),
Name: p.str("name"),
Arguments: parseArguments(p.first("arguments", "args", "input")),
}
case "tool_call_response":
return aiobservabilitytypes.Part{
Type: aiobservabilitytypes.PartTypeToolResult,
ToolCallID: p.str("id"),
Name: p.str("name"),
Content: stringOf(p.first("response", "result", "content", "output")),
IsError: boolOf(p.first("is_error", "isError")),
}
case "":
// Gemini parts carry no type; the field name is the type.
if text, ok := p.get("text"); ok {
if p.flag("thought") {
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: stringOf(text)}
}
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeText, Content: stringOf(text)}
}
if call, ok := toObject(p.first("functionCall", "function_call")); ok {
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeToolCall, ID: call.str("id"), Name: call.str("name"), Arguments: parseArguments(call.at("args"))}
}
if resp, ok := toObject(p.first("functionResponse", "function_response")); ok {
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: resp.str("id"), Name: resp.str("name"), Content: resp.str("response")}
}
}
return genericPart(p)
}
// toolCallPart reads the OpenAI, flat, Anthropic and Vercel tool call shapes.
func toolCallPart(value any) aiobservabilitytypes.Part {
call, ok := toObject(value)
if !ok {
return genericPart(value)
}
part := aiobservabilitytypes.Part{
Type: aiobservabilitytypes.PartTypeToolCall,
ID: stringOf(call.first("toolCallId", "call_id", "id")),
Name: stringOf(call.first("toolName", "name")),
}
if fn, ok := call.obj("function"); ok {
part.Name = fn.str("name")
part.Arguments = parseArguments(fn.at("arguments"))
return part
}
part.Arguments = parseArguments(call.first("arguments", "args", "input"))
return part
}
// toolResultBlockPart unwraps the Vercel {type, value} result wrapper.
func toolResultBlockPart(p object, result any) aiobservabilitytypes.Part {
if nested, ok := toObject(result); ok && len(nested) <= 2 {
if v, ok := nested.get("value"); ok {
result = v
}
}
return aiobservabilitytypes.Part{
Type: aiobservabilitytypes.PartTypeToolResult,
ToolCallID: stringOf(p.first("toolCallId", "tool_use_id", "tool_call_id", "call_id", "id")),
Name: stringOf(p.first("toolName", "name")),
Content: toolResultText(result),
IsError: boolOf(p.first("isError", "is_error")),
}
}
// toolResultText joins a list of text blocks; anything else goes through stringOf.
func toolResultText(result any) string {
list, ok := result.([]any)
if !ok || len(list) == 0 {
return stringOf(result)
}
texts := make([]string, 0, len(list))
for _, item := range list {
block := asObject(item)
text, ok := block.text("text")
if !ok || (len(block) == 2 && block.str("type") != "text") || len(block) > 2 {
return stringOf(result)
}
texts = append(texts, text)
}
return strings.Join(texts, "\n")
}
func textPart(content string) aiobservabilitytypes.Part {
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeText, Content: content}
}
func genericPart(value any) aiobservabilitytypes.Part {
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeGeneric, Content: stringOf(value)}
}

View File

@@ -0,0 +1,130 @@
package genaimessages
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
)
func TestNormalizePartContent(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "PartWithUnknownType_BecomesGeneric",
raw: `[{"role":"user","parts":[{"type":"image","url":"http://x/y.png"}]}]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeGeneric, Content: `{"type":"image","url":"http://x/y.png"}`},
},
},
},
},
})
}
func TestNormalizePartThinking(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "ThoughtReasoningAndHiddenParts_BecomeThinking",
raw: `[
{
"role": "assistant",
"parts": [
{"type": "text", "content": "Let me think", "thought": true},
{"type": "reasoning", "content": "step 1"},
{"type": "redacted_reasoning", "data": "xyz"},
{"type": "text", "content": "Answer"}
]
}
]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeThinking, Content: "Let me think"},
{Type: aiobservabilitytypes.PartTypeThinking, Content: "step 1"},
{Type: aiobservabilitytypes.PartTypeThinking},
{Type: aiobservabilitytypes.PartTypeText, Content: "Answer"},
},
},
},
},
})
}
func TestNormalizePartTools(t *testing.T) {
assertNormalize(t, []normalizeCase{
{
name: "AnthropicServerToolUseAndWebSearchResult_BecomeToolCallAndResult",
raw: `[
{
"role": "assistant",
"content": [
{"type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": {"query": "SigNoz"}},
{"type": "web_search_tool_result", "tool_use_id": "srvtoolu_1", "content": [{"type": "web_search_result", "title": "SigNoz", "url": "https://signoz.io"}]}
]
}
]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "srvtoolu_1", Name: "web_search", Arguments: map[string]any{"query": "SigNoz"}},
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "srvtoolu_1", Content: `[{"title":"SigNoz","type":"web_search_result","url":"https://signoz.io"}]`},
},
},
},
},
{
name: "AnthropicToolResultTextBlocks_JoinedIntoOneString",
raw: `[
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "t1",
"content": [
{"type": "text", "text": "line one"},
{"type": "text", "text": "line two"}
]
}
]
}
]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleUser,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "t1", Content: "line one\nline two"},
},
},
},
},
{
name: "VercelToolResultJSONWrapper_ValueUnwrapped",
raw: `[{"role": "tool", "content": [{"type": "tool-result", "toolCallId": "call_1", "toolName": "get_weather", "result": {"type": "json", "value": {"temp_c": 18}}}]}]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleTool,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "call_1", Name: "get_weather", Content: `{"temp_c":18}`},
},
},
},
},
{
name: "ToolCallWithInvalidJSONArguments_ArgumentsKeptAsString",
raw: `[{"role": "assistant", "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{city: Paris"}}]}]`,
want: []aiobservabilitytypes.Message{
{
Role: aiobservabilitytypes.MessageRoleAssistant,
Content: []aiobservabilitytypes.Part{
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "call_1", Name: "get_weather", Arguments: "{city: Paris"},
},
},
},
},
})
}

View File

@@ -0,0 +1,54 @@
package genaimessages
import (
"strings"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
)
var finishReasonKeys = []string{"finish_reason", "finishReason", "stop_reason"}
// normalizeRole keeps an unknown role, lowercased.
func normalizeRole(role string) aiobservabilitytypes.MessageRole {
if known := standardRole(role); known != "" {
return known
}
return aiobservabilitytypes.MessageRole(strings.ToLower(strings.TrimSpace(role)))
}
// normalizeFinishReason keeps an unknown reason, lowercased.
func normalizeFinishReason(reason string) aiobservabilitytypes.FinishReason {
lowered := strings.ToLower(strings.TrimSpace(reason))
switch lowered {
case "stop", "end_turn", "stop_sequence", "completed", "complete", "eos", "finished":
return aiobservabilitytypes.FinishReasonStop
case "tool_call", "tool_calls", "tool_use", "function_call":
return aiobservabilitytypes.FinishReasonToolCall
case "length", "max_tokens", "max_output_tokens", "max_completion_tokens", "model_length":
return aiobservabilitytypes.FinishReasonLength
case "content_filter", "content_filtered", "guardrail_intervened", "safety", "refusal", "recitation", "blocklist", "prohibited_content", "spii":
return aiobservabilitytypes.FinishReasonContentFilter
case "error", "failed", "incomplete":
return aiobservabilitytypes.FinishReasonError
}
return aiobservabilitytypes.FinishReason(lowered)
}
// standardRole maps vendor role names onto MessageRole; anything else is "".
func standardRole(role string) aiobservabilitytypes.MessageRole {
switch strings.ToLower(strings.TrimSpace(role)) {
case "system", "developer":
return aiobservabilitytypes.MessageRoleSystem
case "user", "human":
return aiobservabilitytypes.MessageRoleUser
case "assistant", "ai", "model":
return aiobservabilitytypes.MessageRoleAssistant
case "tool", "function":
return aiobservabilitytypes.MessageRoleTool
}
return ""
}
func finishReasonOf(m object) string {
return stringOf(m.first(finishReasonKeys...))
}

View File

@@ -0,0 +1,53 @@
package genaimessages
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/stretchr/testify/assert"
)
func TestNormalizeRole(t *testing.T) {
testCases := []struct {
name string
roles []string
want aiobservabilitytypes.MessageRole
}{
{name: "SystemAndDeveloper_MapToSystem", roles: []string{"system", "developer", " Developer "}, want: aiobservabilitytypes.MessageRoleSystem},
{name: "UserAndHuman_MapToUser", roles: []string{"user", "human", "HUMAN"}, want: aiobservabilitytypes.MessageRoleUser},
{name: "AssistantAIAndModel_MapToAssistant", roles: []string{"assistant", "ai", "model"}, want: aiobservabilitytypes.MessageRoleAssistant},
{name: "ToolAndFunction_MapToTool", roles: []string{"tool", "function"}, want: aiobservabilitytypes.MessageRoleTool},
{name: "UnknownRole_KeptLowercased", roles: []string{"Narrator"}, want: "narrator"},
{name: "EmptyRole_StaysEmpty", roles: []string{"", " "}, want: ""},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
for _, role := range testCase.roles {
assert.Equal(t, testCase.want, normalizeRole(role), role)
}
})
}
}
func TestNormalizeFinishReason(t *testing.T) {
testCases := []struct {
name string
reasons []string
want aiobservabilitytypes.FinishReason
}{
{name: "EndOfAnswer_MapsToStop", reasons: []string{"stop", "end_turn", "stop_sequence", "completed", "complete", "eos", "finished", "STOP"}, want: aiobservabilitytypes.FinishReasonStop},
{name: "ToolCallVariants_MapToToolCall", reasons: []string{"tool_call", "tool_calls", "tool_use", "function_call"}, want: aiobservabilitytypes.FinishReasonToolCall},
{name: "TokenLimits_MapToLength", reasons: []string{"length", "max_tokens", "max_output_tokens", "max_completion_tokens", "model_length", "MAX_TOKENS"}, want: aiobservabilitytypes.FinishReasonLength},
{name: "SafetyBlocks_MapToContentFilter", reasons: []string{"content_filter", "content_filtered", "guardrail_intervened", "safety", "refusal", "recitation", "blocklist", "prohibited_content", "spii", "SAFETY"}, want: aiobservabilitytypes.FinishReasonContentFilter},
{name: "Failures_MapToError", reasons: []string{"error", "failed", "incomplete"}, want: aiobservabilitytypes.FinishReasonError},
{name: "UnknownReason_KeptLowercased", reasons: []string{"Weird"}, want: "weird"},
{name: "EmptyReason_StaysEmpty", reasons: []string{"", " "}, want: ""},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
for _, reason := range testCase.reasons {
assert.Equal(t, testCase.want, normalizeFinishReason(reason), reason)
}
})
}
}

View File

@@ -0,0 +1 @@
[{"role":"user","content":"What's the weather in Bengaluru? Use the tool."},{"role":"assistant","content":"","tool_calls":[{"id":"call_yGnJsGOZMnHKTA2x1XM8897b","type":"function","name":"get_current_weather","args":"{\"city\":\"Bengaluru\"}"}]},{"role":"tool","content":"{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"}]

View File

@@ -0,0 +1,33 @@
[
{
"role": "user",
"content": [
{
"type": "text",
"content": "What's the weather in Bengaluru? Use the tool."
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_yGnJsGOZMnHKTA2x1XM8897b",
"name": "get_current_weather",
"arguments": {
"city": "Bengaluru"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"
}
]
}
]

View File

@@ -0,0 +1 @@
[{"role":"assistant","content":"","tool_calls":[{"id":"call_yGnJsGOZMnHKTA2x1XM8897b","type":"function","name":"get_current_weather","args":"{\"city\":\"Bengaluru\"}"}]}]

View File

@@ -0,0 +1,15 @@
[
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_yGnJsGOZMnHKTA2x1XM8897b",
"name": "get_current_weather",
"arguments": {
"city": "Bengaluru"
}
}
]
}
]

View File

@@ -0,0 +1 @@
[{"role": "user", "parts": [{"content": "What's the weather in Bengaluru? Use the tool.", "type": "text"}]}, {"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_iXrmKomEbGycID5aX4BYrQcg", "arguments": {"city": "Bengaluru"}}]}, {"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_iXrmKomEbGycID5aX4BYrQcg", "response": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"}]}]

View File

@@ -0,0 +1,34 @@
[
{
"role": "user",
"content": [
{
"type": "text",
"content": "What's the weather in Bengaluru? Use the tool."
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_iXrmKomEbGycID5aX4BYrQcg",
"name": "get_current_weather",
"arguments": {
"city": "Bengaluru"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}",
"toolCallId": "call_iXrmKomEbGycID5aX4BYrQcg"
}
]
}
]

View File

@@ -0,0 +1 @@
[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_iXrmKomEbGycID5aX4BYrQcg", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]

View File

@@ -0,0 +1,16 @@
[
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_iXrmKomEbGycID5aX4BYrQcg",
"name": "get_current_weather",
"arguments": {
"city": "Bengaluru"
}
}
],
"finishReason": "tool_call"
}
]

View File

@@ -0,0 +1 @@
[{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Bengaluru?"}]}, {"role": "assistant", "parts": [{"type": "tool_call", "id": "call_mnRhZ0fYlvrSujkEcdmpppNw", "name": "get_weather", "arguments": {"city": "Bengaluru"}}]}, {"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_mnRhZ0fYlvrSujkEcdmpppNw", "response": "{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}"}]}]

View File

@@ -0,0 +1,34 @@
[
{
"role": "user",
"content": [
{
"type": "text",
"content": "What's the weather in Bengaluru?"
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_mnRhZ0fYlvrSujkEcdmpppNw",
"name": "get_weather",
"arguments": {
"city": "Bengaluru"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}",
"toolCallId": "call_mnRhZ0fYlvrSujkEcdmpppNw"
}
]
}
]

View File

@@ -0,0 +1 @@
[{"role": "assistant", "parts": [{"type": "tool_call", "id": "call_mnRhZ0fYlvrSujkEcdmpppNw", "name": "get_weather", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]

View File

@@ -0,0 +1,16 @@
[
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_mnRhZ0fYlvrSujkEcdmpppNw",
"name": "get_weather",
"arguments": {
"city": "Bengaluru"
}
}
],
"finishReason": "tool_call"
}
]

View File

@@ -0,0 +1 @@
{"generations":[[{"text":"","generation_info":{"finish_reason":"tool_calls","logprobs":null},"type":"ChatGeneration","message":{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"","additional_kwargs":{"refusal":null},"response_metadata":{"token_usage":{"completion_tokens":17,"prompt_tokens":63,"total_tokens":80,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0}},"model_provider":"openai","model_name":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_5feb5fe6c2","id":"chatcmpl-Dm101OqaY01WKntqj29Epz5er05ry","service_tier":"default","finish_reason":"tool_calls","logprobs":null},"type":"ai","id":"lc_run--019e844a-d27a-7c23-b1ed-a542e0f855da-0","tool_calls":[{"name":"get_weather","args":{"city":"Bengaluru"},"id":"call_nbrbkc5fom95q8uMA2bCvH6s","type":"tool_call"}],"usage_metadata":{"input_tokens":63,"output_tokens":17,"total_tokens":80,"input_token_details":{"audio":0,"cache_read":0},"output_token_details":{"audio":0,"reasoning":0}},"invalid_tool_calls":[]}}}]],"llm_output":{"token_usage":{"completion_tokens":17,"prompt_tokens":63,"total_tokens":80,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0}},"model_provider":"openai","model_name":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_5feb5fe6c2","id":"chatcmpl-Dm101OqaY01WKntqj29Epz5er05ry","service_tier":"default"},"run":null,"type":"LLMResult"}

View File

@@ -0,0 +1,16 @@
[
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_nbrbkc5fom95q8uMA2bCvH6s",
"name": "get_weather",
"arguments": {
"city": "Bengaluru"
}
}
],
"finishReason": "tool_call"
}
]

View File

@@ -0,0 +1 @@
{"messages":[[{"lc":1,"type":"constructor","id":["langchain","schema","messages","SystemMessage"],"kwargs":{"content":"You are a weather assistant. Call get_weather once per city.","type":"system"}},{"lc":1,"type":"constructor","id":["langchain","schema","messages","HumanMessage"],"kwargs":{"content":"What's the weather in Bengaluru?","type":"human"}},{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"","additional_kwargs":{"refusal":null},"response_metadata":{"token_usage":{"completion_tokens":17,"prompt_tokens":63,"total_tokens":80,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0}},"model_provider":"openai","model_name":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_5feb5fe6c2","id":"chatcmpl-Dm101OqaY01WKntqj29Epz5er05ry","service_tier":"default","finish_reason":"tool_calls","logprobs":null},"type":"ai","id":"lc_run--019e844a-d27a-7c23-b1ed-a542e0f855da-0","tool_calls":[{"name":"get_weather","args":{"city":"Bengaluru"},"id":"call_nbrbkc5fom95q8uMA2bCvH6s","type":"tool_call"}],"usage_metadata":{"input_tokens":63,"output_tokens":17,"total_tokens":80,"input_token_details":{"audio":0,"cache_read":0},"output_token_details":{"audio":0,"reasoning":0}},"invalid_tool_calls":[]}},{"lc":1,"type":"constructor","id":["langchain","schema","messages","ToolMessage"],"kwargs":{"content":"{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}","type":"tool","tool_call_id":"call_nbrbkc5fom95q8uMA2bCvH6s","status":"success"}}]]}

View File

@@ -0,0 +1,43 @@
[
{
"role": "system",
"content": [
{
"type": "text",
"content": "You are a weather assistant. Call get_weather once per city."
}
]
},
{
"role": "user",
"content": [
{
"type": "text",
"content": "What's the weather in Bengaluru?"
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_nbrbkc5fom95q8uMA2bCvH6s",
"name": "get_weather",
"arguments": {
"city": "Bengaluru"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}",
"toolCallId": "call_nbrbkc5fom95q8uMA2bCvH6s"
}
]
}
]

View File

@@ -0,0 +1 @@
[{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Bengaluru? Use the tool."}]}, {"role": "assistant", "parts": [], "tool_calls": [{"function": {"arguments": "{\"city\":\"Bengaluru\"}", "name": "get_current_weather"}, "id": "call_GuU9siyYfjnx3DfHKzFbRrvD", "type": "function"}]}, {"role": "tool", "parts": [{"type": "text", "content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"}], "tool_call_id": "call_GuU9siyYfjnx3DfHKzFbRrvD"}]

View File

@@ -0,0 +1,34 @@
[
{
"role": "user",
"content": [
{
"type": "text",
"content": "What's the weather in Bengaluru? Use the tool."
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_GuU9siyYfjnx3DfHKzFbRrvD",
"name": "get_current_weather",
"arguments": {
"city": "Bengaluru"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}",
"toolCallId": "call_GuU9siyYfjnx3DfHKzFbRrvD"
}
]
}
]

View File

@@ -0,0 +1 @@
[{"role": "assistant", "parts": [{"type": "text", "content": "The animal in the image is a cartoon duck."}], "finish_reason": "stop"}]

View File

@@ -0,0 +1,12 @@
[
{
"role": "assistant",
"content": [
{
"type": "text",
"content": "The animal in the image is a cartoon duck."
}
],
"finishReason": "stop"
}
]

View File

@@ -0,0 +1 @@
[{"role": "user", "parts": [{"content": "What's the weather in Bengaluru? Use the tool.", "type": "text"}]}, {"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_itnlpgQcfe0fb0nLJym7DTnt", "arguments": {"city": "Bengaluru"}}]}, {"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_itnlpgQcfe0fb0nLJym7DTnt", "response": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"}]}]

View File

@@ -0,0 +1,34 @@
[
{
"role": "user",
"content": [
{
"type": "text",
"content": "What's the weather in Bengaluru? Use the tool."
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_itnlpgQcfe0fb0nLJym7DTnt",
"name": "get_current_weather",
"arguments": {
"city": "Bengaluru"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}",
"toolCallId": "call_itnlpgQcfe0fb0nLJym7DTnt"
}
]
}
]

View File

@@ -0,0 +1 @@
[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_itnlpgQcfe0fb0nLJym7DTnt", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]

View File

@@ -0,0 +1,16 @@
[
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_itnlpgQcfe0fb0nLJym7DTnt",
"name": "get_current_weather",
"arguments": {
"city": "Bengaluru"
}
}
],
"finishReason": "tool_call"
}
]

View File

@@ -0,0 +1 @@
[{"role":"system","content":"You are a weather assistant. When asked about weather, call get-weather once per city and summarise."},{"role":"user","content":[{"type":"text","text":"What is the weather in Bengaluru?"}]},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_526phoykytobsvLouzES805J","toolName":"getWeather","args":{"city":"Bengaluru"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_526phoykytobsvLouzES805J","toolName":"getWeather","result":{"city":"Bengaluru","temperatureC":18,"summary":"Clear"}}]}]

View File

@@ -0,0 +1,44 @@
[
{
"role": "system",
"content": [
{
"type": "text",
"content": "You are a weather assistant. When asked about weather, call get-weather once per city and summarise."
}
]
},
{
"role": "user",
"content": [
{
"type": "text",
"content": "What is the weather in Bengaluru?"
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_526phoykytobsvLouzES805J",
"name": "getWeather",
"arguments": {
"city": "Bengaluru"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\":\"Bengaluru\",\"summary\":\"Clear\",\"temperatureC\":18}",
"name": "getWeather",
"toolCallId": "call_526phoykytobsvLouzES805J"
}
]
}
]

View File

@@ -0,0 +1 @@
The current weather in Bengaluru is clear with a temperature of 18°C.

View File

@@ -0,0 +1,10 @@
[
{
"content": [
{
"type": "text",
"content": "The current weather in Bengaluru is clear with a temperature of 18°C."
}
]
}
]

View File

@@ -0,0 +1 @@
[{"toolCallType":"function","toolCallId":"call_526phoykytobsvLouzES805J","toolName":"getWeather","args":"{\"city\":\"Bengaluru\"}"}]

View File

@@ -0,0 +1,15 @@
[
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_526phoykytobsvLouzES805J",
"name": "getWeather",
"arguments": {
"city": "Bengaluru"
}
}
]
}
]

View File

@@ -0,0 +1 @@
{"include": [], "input": [{"content": "What's the weather in Bangalore right now?", "role": "user"}, {"arguments": "{\"city\":\"Bangalore\"}", "call_id": "call_MQTQ0XYL95vA5k31cbCgRt5y", "name": "get_weather", "type": "function_call", "id": "fc_0368c3e62fb9ae12006a199f7c14c481a1a22444594cc574e4", "status": "completed"}, {"call_id": "call_MQTQ0XYL95vA5k31cbCgRt5y", "output": "27C, partly cloudy, light breeze from the south.", "type": "function_call_output"}], "instructions": "You are a concise weather assistant. When asked about weather, call the get_weather tool exactly once per city, then summarise the result in one sentence.", "model": "gpt-4o-mini", "tools": [{"name": "get_weather", "parameters": {"properties": {"city": {"title": "City", "type": "string"}}, "required": ["city"], "title": "get_weather_args", "type": "object", "additionalProperties": false}, "strict": true, "type": "function", "description": "Look up the current weather for a city. Returns a short summary string."}]}

View File

@@ -0,0 +1,43 @@
[
{
"role": "system",
"content": [
{
"type": "text",
"content": "You are a concise weather assistant. When asked about weather, call the get_weather tool exactly once per city, then summarise the result in one sentence."
}
]
},
{
"role": "user",
"content": [
{
"type": "text",
"content": "What's the weather in Bangalore right now?"
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_MQTQ0XYL95vA5k31cbCgRt5y",
"name": "get_weather",
"arguments": {
"city": "Bangalore"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "27C, partly cloudy, light breeze from the south.",
"toolCallId": "call_MQTQ0XYL95vA5k31cbCgRt5y"
}
]
}
]

View File

@@ -0,0 +1 @@
{"id":"resp_017c5eb995d99968006a199f9b483481979ca790db905506fc","created_at":1780064155.0,"error":null,"incomplete_details":null,"instructions":"You solve problems step by step and give a final answer.","metadata":{},"model":"o3-mini-2025-01-31","object":"response","output":[{"id":"rs_017c5eb995d99968006a199f9bff248197978bf9193f34c29a","summary":[],"type":"reasoning"},{"id":"msg_017c5eb995d99968006a199f9c8dcc81979f4e10afbe5350a1","content":[{"annotations":[],"text":"Step 1: Let the cost of the ball be x dollars.\nStep 2: Since the bat costs $1 more than the ball, its cost is x + 1 dollars.\nStep 3: According to the problem, the total cost of the bat and ball is $1.10. Set up the equation:\n  x + (x + 1) = 1.10\nStep 4: Simplify the equation:\n  2x + 1 = 1.10\nStep 5: Subtract 1 from both sides:\n  2x = 0.10\nStep 6: Divide both sides by 2:\n  x = 0.05\n\nFinal Answer: The ball costs $0.05.","type":"output_text","logprobs":[]}],"role":"assistant","status":"completed","type":"message"}],"parallel_tool_calls":true,"temperature":1.0,"tool_choice":"auto","tools":[],"top_p":1.0,"background":false,"completed_at":1780064157.0,"max_output_tokens":null,"max_tool_calls":null,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"effort":"medium","summary":null,"context":"current_turn"},"safety_identifier":null,"service_tier":"default","status":"completed","text":{"format":{"type":"text"},"verbosity":"medium"},"top_logprobs":0,"truncation":"disabled","usage":{"input_tokens":49,"input_tokens_details":{"cached_tokens":0},"output_tokens":431,"output_tokens_details":{"reasoning_tokens":128},"total_tokens":480},"user":null,"billing":{"payer":"developer"},"frequency_penalty":0.0,"moderation":null,"presence_penalty":0.0,"store":true}

View File

@@ -0,0 +1,20 @@
[
{
"role": "assistant",
"content": [
{
"type": "thinking"
}
]
},
{
"role": "assistant",
"content": [
{
"type": "text",
"content": "Step 1: Let the cost of the ball be x dollars.\nStep 2: Since the bat costs $1 more than the ball, its cost is x + 1 dollars.\nStep 3: According to the problem, the total cost of the bat and ball is $1.10. Set up the equation:\n  x + (x + 1) = 1.10\nStep 4: Simplify the equation:\n  2x + 1 = 1.10\nStep 5: Subtract 1 from both sides:\n  2x = 0.10\nStep 6: Divide both sides by 2:\n  x = 0.05\n\nFinal Answer: The ball costs $0.05."
}
],
"finishReason": "stop"
}
]

View File

@@ -0,0 +1 @@
{"messages": [{"role": "user", "content": "What's the weather in Paris and in London? Use the tool for each."}, {"content": null, "refusal": null, "role": "assistant", "annotations": [], "audio": null, "function_call": null, "tool_calls": [{"id": "call_SW4hst7nzysgvWSAz4OdyK9D", "function": {"arguments": "{\"city\": \"Paris\"}", "name": "get_current_weather"}, "type": "function"}, {"id": "call_si8wiETYGpooMB7gMXSgMQpk", "function": {"arguments": "{\"city\": \"London\"}", "name": "get_current_weather"}, "type": "function"}]}, {"role": "tool", "tool_call_id": "call_SW4hst7nzysgvWSAz4OdyK9D", "content": "{\"city\": \"Paris\", \"temp_c\": 18, \"summary\": \"Clear\"}"}, {"role": "tool", "tool_call_id": "call_si8wiETYGpooMB7gMXSgMQpk", "content": "{\"city\": \"London\", \"temp_c\": 18, \"summary\": \"Clear\"}"}], "model": "gpt-4o-mini", "max_tokens": 80}

View File

@@ -0,0 +1,52 @@
[
{
"role": "user",
"content": [
{
"type": "text",
"content": "What's the weather in Paris and in London? Use the tool for each."
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_SW4hst7nzysgvWSAz4OdyK9D",
"name": "get_current_weather",
"arguments": {
"city": "Paris"
}
},
{
"type": "tool_call",
"id": "call_si8wiETYGpooMB7gMXSgMQpk",
"name": "get_current_weather",
"arguments": {
"city": "London"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\": \"Paris\", \"temp_c\": 18, \"summary\": \"Clear\"}",
"toolCallId": "call_SW4hst7nzysgvWSAz4OdyK9D"
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\": \"London\", \"temp_c\": 18, \"summary\": \"Clear\"}",
"toolCallId": "call_si8wiETYGpooMB7gMXSgMQpk"
}
]
}
]

View File

@@ -0,0 +1 @@
{"id":"chatcmpl-DksB6cYbMPwTBkJixOAuxKGZpQKds","choices":[{"finish_reason":"tool_calls","index":0,"logprobs":null,"message":{"content":null,"refusal":null,"role":"assistant","annotations":[],"tool_calls":[{"id":"call_MdYKTRrkshuBG9W5oBXYZaRc","function":{"arguments":"{\"city\":\"Bengaluru\",\"unit\":\"c\"}","name":"get_current_weather"},"type":"function"}]}}],"created":1780063720,"model":"gpt-4o-mini-2024-07-18","object":"chat.completion","service_tier":"default","system_fingerprint":"fp_c6aa0586c9","usage":{"completion_tokens":22,"prompt_tokens":65,"total_tokens":87,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0}}}

View File

@@ -0,0 +1,17 @@
[
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_MdYKTRrkshuBG9W5oBXYZaRc",
"name": "get_current_weather",
"arguments": {
"city": "Bengaluru",
"unit": "c"
}
}
],
"finishReason": "tool_call"
}
]

View File

@@ -0,0 +1 @@
[{"role": "user", "parts": [{"content": "What's the weather in Bengaluru? Use the tool.", "type": "text"}]}, {"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_TaqNOsbmsz4ifWLVyDHYAAEK", "arguments": {"city": "Bengaluru"}}]}, {"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_TaqNOsbmsz4ifWLVyDHYAAEK", "response": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"}]}]

View File

@@ -0,0 +1,34 @@
[
{
"role": "user",
"content": [
{
"type": "text",
"content": "What's the weather in Bengaluru? Use the tool."
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_TaqNOsbmsz4ifWLVyDHYAAEK",
"name": "get_weather",
"arguments": {
"city": "Bengaluru"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}",
"toolCallId": "call_TaqNOsbmsz4ifWLVyDHYAAEK"
}
]
}
]

View File

@@ -0,0 +1 @@
[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_TaqNOsbmsz4ifWLVyDHYAAEK", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]

View File

@@ -0,0 +1,16 @@
[
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_TaqNOsbmsz4ifWLVyDHYAAEK",
"name": "get_weather",
"arguments": {
"city": "Bengaluru"
}
}
],
"finishReason": "tool_call"
}
]

View File

@@ -0,0 +1 @@
{"completion":"Let x be the cost of the ball. Then the bat costs x + 1 dollars. The total cost is given as:\n\n  x + (x + 1) = 1.10\n\nCombine like terms:\n\n  2x + 1 = 1.10\n\nSubtract 1 from both sides:\n\n  2x = 0.10\n\nDivide both sides by 2:\n\n  x = 0.05\n\nSo, the ball costs 5 cents.","reasoning":"**Calculating ball price**\n\nI'm determining the price of the ball based on a scenario where a bat and ball combined cost $1.10, with the bat costing $1 more than the ball. I set up the equation: let the cost of the ball be x, making the bat x + 1. Solving the equation x + (x + 1) = 1.10 leads me to find that x equals $0.05 for the ball. I need to confirm that this works: the ball at $0.05 and the bat at $1.05 totals $1.10.","rawRequest":{"model":"openai/o3-mini","max_completion_tokens":2000}}

View File

@@ -0,0 +1,15 @@
[
{
"role": "assistant",
"content": [
{
"type": "thinking",
"content": "**Calculating ball price**\n\nI'm determining the price of the ball based on a scenario where a bat and ball combined cost $1.10, with the bat costing $1 more than the ball. I set up the equation: let the cost of the ball be x, making the bat x + 1. Solving the equation x + (x + 1) = 1.10 leads me to find that x equals $0.05 for the ball. I need to confirm that this works: the ball at $0.05 and the bat at $1.05 totals $1.10."
},
{
"type": "text",
"content": "Let x be the cost of the ball. Then the bat costs x + 1 dollars. The total cost is given as:\n\n  x + (x + 1) = 1.10\n\nCombine like terms:\n\n  2x + 1 = 1.10\n\nSubtract 1 from both sides:\n\n  2x = 0.10\n\nDivide both sides by 2:\n\n  x = 0.05\n\nSo, the ball costs 5 cents."
}
]
}
]

View File

@@ -0,0 +1 @@
{"messages":[{"role":"user","content":"What's the weather in Bengaluru? Use the tool."},{"content":null,"refusal":null,"role":"assistant","annotations":null,"audio":null,"function_call":null,"tool_calls":[{"id":"call_AwiGdiFjghGVhbmbGtt92rjs","function":{"arguments":"{\"city\":\"Bengaluru\"}","name":"get_current_weather"},"type":"function","index":0}],"reasoning":null},{"role":"tool","tool_call_id":"call_AwiGdiFjghGVhbmbGtt92rjs","content":"{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"}]}

View File

@@ -0,0 +1,34 @@
[
{
"role": "user",
"content": [
{
"type": "text",
"content": "What's the weather in Bengaluru? Use the tool."
}
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_call",
"id": "call_AwiGdiFjghGVhbmbGtt92rjs",
"name": "get_current_weather",
"arguments": {
"city": "Bengaluru"
}
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}",
"toolCallId": "call_AwiGdiFjghGVhbmbGtt92rjs"
}
]
}
]

Some files were not shown because too many files have changed in this diff Show More