Compare commits

..

1 Commits

Author SHA1 Message Date
aks07
8dc4c0ce76 fix(hotkeys): ignore keydown events whose target is not an element 2026-10-15 23:59:50 +05:30
103 changed files with 1198 additions and 4662 deletions

View File

@@ -3,44 +3,16 @@ name: e2eci
on:
pull_request:
types:
- opened
- synchronize
- reopened
- labeled
pull_request_target:
types:
- labeled
merge_group:
types:
- checks_requested
concurrency:
group: e2eci-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
changes:
if: github.event_name != 'merge_group'
runs-on: ubuntu-latest
outputs:
e2e: ${{ steps.changes.outputs.e2e }}
steps:
- name: changes
id: changes
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
LABELED: ${{ contains(github.event.pull_request.labels.*.name, 'safe-to-e2e') }}
run: |
files=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename')
if [ "$LABELED" = true ] || grep -qE '^(frontend|tests/e2e)/' <<< "$files"; then
echo "e2e=true" >> "$GITHUB_OUTPUT"
fi
fmtlint:
needs: changes
if: |
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && needs.changes.outputs.e2e == 'true'
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
runs-on: ubuntu-latest
steps:
- name: checkout
@@ -69,10 +41,9 @@ jobs:
project:
- chromium
shard: [1, 2, 3, 4]
needs: changes
if: |
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && needs.changes.outputs.e2e == 'true'
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -181,17 +152,3 @@ jobs:
name: playwright-artifacts-${{ matrix.project }}-${{ matrix.shard }}
path: tests/e2e/artifacts/
retention-days: 5
# Required status check. Fails when e2e was needed but did not pass,
# including fork and dependabot PRs still waiting on safe-to-test.
e2e:
if: always()
needs: [changes, fmtlint, test]
runs-on: ubuntu-latest
steps:
- name: verdict
if: |
github.event_name != 'merge_group' && (needs.changes.result != 'success' ||
(needs.changes.outputs.e2e == 'true' && (needs.fmtlint.result != 'success' || needs.test.result != 'success')))
run: |
echo "::error::e2e did not pass. Fork and dependabot PRs need the safe-to-test label."
exit 1

View File

@@ -8046,31 +8046,6 @@ components:
- metric
- value
type: object
PromotetypesIndexMaterializedPathsResult:
properties:
indexed:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
skipped:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
required:
- indexed
- skipped
type: object
PromotetypesPostableIndexMaterializedPaths:
properties:
dryRun:
type: boolean
signal:
type: string
required:
- signal
type: object
PromotetypesPromotePath:
properties:
context:
@@ -14097,67 +14072,6 @@ paths:
tags:
- promote
x-signoz-stability: alpha
/api/v1/promoted_paths/materialized:
post:
deprecated: false
description: This endpoint indexes the JSON sub-columns of materialized paths
whose columns are indexed and returns the indexed and skipped paths.
operationId: IndexMaterializedPaths
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PromotetypesPostableIndexMaterializedPaths'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/PromotetypesIndexMaterializedPathsResult'
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
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- logs-field:update
- traces-field:update
- tokenizer:
- logs-field:update
- traces-field:update
summary: Index materialized paths
tags:
- promote
x-signoz-stability: alpha
/api/v1/roles:
get:
deprecated: false
@@ -16150,8 +16064,10 @@ paths:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key: []
- tokenizer: []
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get stats
tags:
- stats
@@ -19000,9 +18916,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: Run Infra Monitoring Setup Checks
tags:
- inframonitoring
@@ -19074,9 +18990,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List Clusters for Infra Monitoring
tags:
- inframonitoring
@@ -19158,9 +19074,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List DaemonSets for Infra Monitoring
tags:
- inframonitoring
@@ -19236,9 +19152,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List Deployments for Infra Monitoring
tags:
- inframonitoring
@@ -19304,9 +19220,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List Hosts for Infra Monitoring
tags:
- inframonitoring
@@ -19386,9 +19302,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List Jobs for Infra Monitoring
tags:
- inframonitoring
@@ -19467,9 +19383,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List Kubernetes Containers for Infra Monitoring
tags:
- inframonitoring
@@ -19538,9 +19454,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List Namespaces for Infra Monitoring
tags:
- inframonitoring
@@ -19613,9 +19529,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List Nodes for Infra Monitoring
tags:
- inframonitoring
@@ -19689,9 +19605,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List Pods for Infra Monitoring
tags:
- inframonitoring
@@ -19760,9 +19676,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List Volumes for Infra Monitoring
tags:
- inframonitoring
@@ -19838,9 +19754,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- metrics:read
- VIEWER
- tokenizer:
- metrics:read
- VIEWER
summary: List StatefulSets for Infra Monitoring
tags:
- inframonitoring

View File

@@ -18,10 +18,8 @@ import type {
} from 'react-query';
import type {
IndexMaterializedPaths200,
ListPromotedPaths200,
ListPromotedPathsParams,
PromotetypesPostableIndexMaterializedPathsDTO,
PromotetypesPromotePathDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
@@ -232,87 +230,3 @@ export const usePromotePaths = <
> => {
return useMutation(getPromotePathsMutationOptions(options));
};
/**
* This endpoint indexes the JSON sub-columns of materialized paths whose columns are indexed and returns the indexed and skipped paths.
* @summary Index materialized paths
*/
export const indexMaterializedPaths = (
promotetypesPostableIndexMaterializedPathsDTO?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<IndexMaterializedPaths200>({
url: `/api/v1/promoted_paths/materialized`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: promotetypesPostableIndexMaterializedPathsDTO,
signal,
});
};
export const getIndexMaterializedPathsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof indexMaterializedPaths>>,
TError,
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof indexMaterializedPaths>>,
TError,
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
TContext
> => {
const mutationKey = ['indexMaterializedPaths'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof indexMaterializedPaths>>,
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> }
> = (props) => {
const { data } = props ?? {};
return indexMaterializedPaths(data);
};
return { mutationFn, ...mutationOptions };
};
export type IndexMaterializedPathsMutationResult = NonNullable<
Awaited<ReturnType<typeof indexMaterializedPaths>>
>;
export type IndexMaterializedPathsMutationBody =
| BodyType<PromotetypesPostableIndexMaterializedPathsDTO>
| undefined;
export type IndexMaterializedPathsMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Index materialized paths
*/
export const useIndexMaterializedPaths = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof indexMaterializedPaths>>,
TError,
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof indexMaterializedPaths>>,
TError,
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
TContext
> => {
return useMutation(getIndexMaterializedPathsMutationOptions(options));
};

View File

@@ -9878,28 +9878,6 @@ export interface PromotetypesPromotePathDTO {
signal: string;
}
export interface PromotetypesIndexMaterializedPathsResultDTO {
/**
* @type array,null
*/
indexed: PromotetypesPromotePathDTO[] | null;
/**
* @type array,null
*/
skipped: PromotetypesPromotePathDTO[] | null;
}
export interface PromotetypesPostableIndexMaterializedPathsDTO {
/**
* @type boolean
*/
dryRun?: boolean;
/**
* @type string
*/
signal: string;
}
export interface Querybuildertypesv5AggregationMetaDTO {
/**
* @type array
@@ -13272,14 +13250,6 @@ export type ListPromotedPaths200 = {
status: string;
};
export type IndexMaterializedPaths200 = {
data: PromotetypesIndexMaterializedPathsResultDTO;
/**
* @type string
*/
status: string;
};
export type ListRoles200 = {
/**
* @type array

View File

@@ -61,11 +61,7 @@ describe('convertV5ResponseToLegacy', () => {
],
values: [
{ timestamp: 1000, value: 10 } as unknown as TimeSeriesValue,
{
timestamp: 2000,
value: 12,
partial: true,
} as unknown as TimeSeriesValue,
{ timestamp: 2000, value: 12 } as unknown as TimeSeriesValue,
],
} as unknown as TimeSeries,
],
@@ -108,7 +104,7 @@ describe('convertV5ResponseToLegacy', () => {
labels: { 'service.name': 'adservice' },
values: [
{ timestamp: 1000, value: '10' },
{ timestamp: 2000, value: '12', partial: true },
{ timestamp: 2000, value: '12' },
],
metaData: expect.objectContaining({
alias: '__result_0',

View File

@@ -125,7 +125,6 @@ function convertTimeSeriesData(
values: series.values.map((value: any) => ({
timestamp: value.timestamp,
value: String(value.value),
...(value.partial ? { partial: true } : {}),
})),
metaData: {
alias,

View File

@@ -1,3 +1,3 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="#0E9AA7" d="M8.932 20.806c-.369 0-.738.007-1.109 0-.35-.007-.587-.206-.623-.5a.587.587 0 0 1 .53-.636c.79-.062 1.582-.063 2.372-.003a.548.548 0 0 1 .522.602c-.024.326-.253.526-.616.54zM1.792 8.345c-.392 0-.782.008-1.173.002-.327-.006-.577-.22-.614-.512-.037-.293.146-.544.499-.615.192-.032.388-.045.583-.039a81.515 81.515 0 0 1 1.597 0c.163 0 .325.019.483.056.288.073.445.318.411.617-.034.298-.214.477-.515.487-.424.014-.848.004-1.272.004zm7.588 8.417H4.292a2.464 2.464 0 0 1-.326-.007c-.294-.04-.48-.209-.508-.506-.029-.298.11-.501.391-.606.179-.065.365-.051.549-.051 3.347 0 6.695.005 10.042-.006 1.174-.004 2.187-.439 2.993-1.3.69-.738 1.053-1.63 1.16-2.635.085-.788-.027-1.513-.516-2.156-.544-.718-1.28-1.078-2.163-1.082-3.163-.013-6.328-.005-9.487-.01-.336 0-.673-.027-1.007-.058-.29-.027-.45-.201-.469-.492-.021-.317.141-.545.429-.6a1.55 1.55 0 0 1 .29-.015h10.177c1.71.004 3.187 1.038 3.726 2.654.383 1.147.246 2.304-.182 3.416-.824 2.135-2.762 3.448-5.055 3.454-1.652.005-3.304 0-4.956 0zm2.906-13.568c1.533 0 3.066-.008 4.598 0 2.935.018 5.629 1.892 6.653 4.626.442 1.181.538 2.403.412 3.657-.185 1.842-.735 3.552-1.776 5.084-1.608 2.365-3.873 3.68-6.679 4.118-.95.148-1.905.13-2.86.13-.397 0-.61-.181-.633-.51-.025-.351.196-.621.587-.645.434-.026.87-.004 1.305-.016 2.641-.072 4.928-.982 6.74-2.935 1.269-1.37 1.912-3.039 2.13-4.878.151-1.275.135-2.544-.37-3.752-.773-1.85-2.159-2.983-4.068-3.509-.74-.204-1.5-.243-2.26-.247-2.837-.017-5.675-.007-8.511-.007-.12 0-.24.004-.359-.006a.57.57 0 0 1-.517-.536.557.557 0 0 1 .456-.557c.13-.018.261-.024.392-.019h4.762Z"/>
<path fill="#29F1FB" d="M8.932 20.806c-.369 0-.738.007-1.109 0-.35-.007-.587-.206-.623-.5a.587.587 0 0 1 .53-.636c.79-.062 1.582-.063 2.372-.003a.548.548 0 0 1 .522.602c-.024.326-.253.526-.616.54zM1.792 8.345c-.392 0-.782.008-1.173.002-.327-.006-.577-.22-.614-.512-.037-.293.146-.544.499-.615.192-.032.388-.045.583-.039a81.515 81.515 0 0 1 1.597 0c.163 0 .325.019.483.056.288.073.445.318.411.617-.034.298-.214.477-.515.487-.424.014-.848.004-1.272.004zm7.588 8.417H4.292a2.464 2.464 0 0 1-.326-.007c-.294-.04-.48-.209-.508-.506-.029-.298.11-.501.391-.606.179-.065.365-.051.549-.051 3.347 0 6.695.005 10.042-.006 1.174-.004 2.187-.439 2.993-1.3.69-.738 1.053-1.63 1.16-2.635.085-.788-.027-1.513-.516-2.156-.544-.718-1.28-1.078-2.163-1.082-3.163-.013-6.328-.005-9.487-.01-.336 0-.673-.027-1.007-.058-.29-.027-.45-.201-.469-.492-.021-.317.141-.545.429-.6a1.55 1.55 0 0 1 .29-.015h10.177c1.71.004 3.187 1.038 3.726 2.654.383 1.147.246 2.304-.182 3.416-.824 2.135-2.762 3.448-5.055 3.454-1.652.005-3.304 0-4.956 0zm2.906-13.568c1.533 0 3.066-.008 4.598 0 2.935.018 5.629 1.892 6.653 4.626.442 1.181.538 2.403.412 3.657-.185 1.842-.735 3.552-1.776 5.084-1.608 2.365-3.873 3.68-6.679 4.118-.95.148-1.905.13-2.86.13-.397 0-.61-.181-.633-.51-.025-.351.196-.621.587-.645.434-.026.87-.004 1.305-.016 2.641-.072 4.928-.982 6.74-2.935 1.269-1.37 1.912-3.039 2.13-4.878.151-1.275.135-2.544-.37-3.752-.773-1.85-2.159-2.983-4.068-3.509-.74-.204-1.5-.243-2.26-.247-2.837-.017-5.675-.007-8.511-.007-.12 0-.24.004-.359-.006a.57.57 0 0 1-.517-.536.557.557 0 0 1 .456-.557c.13-.018.261-.024.392-.019h4.762Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1 +1,3 @@
<svg role="img" viewBox="-2.625 3 28 28" xmlns="http://www.w3.org/2000/svg"><path fill="#7A8291" d="M.36 8.6h16.7v5.6H6.04c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h11.02v5.6h-5.33c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h4.98c.2 0 .35-.15.35-.35V25.4h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34v-5.6h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34V3.35c0-.2-.16-.35-.35-.35H.36c-.2 0-.36.16-.36.35v4.9c0 .2.16.35.36.35Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="#9CA3AF" height="34" viewBox="0 0 131 34">
<path fill="#9CA3AF" d="M.36 8.6h16.7v5.6H6.04c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h11.02v5.6h-5.33c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h4.98c.2 0 .35-.15.35-.35V25.4h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34v-5.6h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34V3.35c0-.2-.16-.35-.35-.35H.36c-.2 0-.36.16-.36.35v4.9c0 .2.16.35.36.35ZM44.41 14.7c-.5-.5-1.1-.9-1.76-1.18a5.62 5.62 0 0 0-4.6.17c-.73.37-1.32.91-1.75 1.62h-.17V8.59H34.1v16.83h2.04v-1.81h.17c.21.36.47.67.77.94.31.25.65.48 1.01.67.37.18.77.31 1.18.39a6.2 6.2 0 0 0 3.39-.24 5.36 5.36 0 0 0 3.02-3.1c.29-.75.44-1.62.44-2.6v-.47c0-.96-.16-1.83-.47-2.58-.3-.75-.7-1.4-1.23-1.9v-.01Zm-5.87.66a3.9 3.9 0 0 1 4.34.84c.36.35.64.8.83 1.3.2.5.3 1.07.3 1.7v.47c0 .64-.1 1.23-.3 1.74a3.75 3.75 0 0 1-2.06 2.15 4.27 4.27 0 0 1-3.12-.03 3.86 3.86 0 0 1-2.09-2.2c-.2-.52-.3-1.11-.3-1.75v-.29c0-.62.1-1.2.3-1.7v-.01c.21-.53.5-.99.84-1.36.36-.37.78-.66 1.26-.86ZM97.04 8.59H95v4.86h-2.94v1.86H95v8.17c0 .56.17 1.03.53 1.4.37.35.84.54 1.4.54h4.18v-1.87h-3.5c-.2 0-.33-.05-.43-.15-.1-.1-.14-.27-.14-.49v-7.6h4.65v-1.86h-4.65V8.59ZM114.61 15a5.48 5.48 0 0 0-1.8-1.33 5.6 5.6 0 0 0-2.57-.56 6.17 6.17 0 0 0-4.26 1.7 5.6 5.6 0 0 0-1.72 4.2v.57c0 .9.15 1.75.44 2.5a5.58 5.58 0 0 0 5.5 3.67c1.55 0 2.8-.35 3.72-1.04a5.35 5.35 0 0 0 1.91-2.73l.03-.07-1.94-.52-.02.07c-.11.33-.27.64-.46.94-.17.27-.4.52-.7.74-.28.22-.63.39-1.04.51-.41.13-.9.19-1.46.19a3.8 3.8 0 0 1-2.84-1.05 4.07 4.07 0 0 1-1.1-2.7h9.68v-1.6c0-.54-.11-1.12-.34-1.75a5.04 5.04 0 0 0-1.03-1.74Zm-8.25 3.21a3.8 3.8 0 0 1 1.22-2.25 4.19 4.19 0 0 1 3.99-.7c.44.16.83.38 1.17.66.34.27.62.62.82 1.02.21.38.34.8.38 1.27h-7.58ZM129.09 14.42a4.47 4.47 0 0 0-3.37-1.3c-.93 0-1.73.2-2.4.59-.64.39-1.15.97-1.52 1.74h-.17v-2h-2.04v11.97h2.04v-6.23c0-1.26.32-2.28.95-3.02a3.31 3.31 0 0 1 2.65-1.14c.94 0 1.7.3 2.24.9.56.6.83 1.52.83 2.74v6.75h2.04v-7.13c0-1.71-.42-3.02-1.25-3.87ZM88.1 15a5.48 5.48 0 0 0-1.78-1.33 5.6 5.6 0 0 0-2.58-.56 6.17 6.17 0 0 0-4.27 1.7 5.59 5.59 0 0 0-1.71 4.2v.56c0 .92.14 1.76.44 2.51a5.6 5.6 0 0 0 5.5 3.67c1.55 0 2.8-.35 3.72-1.04a5.36 5.36 0 0 0 1.91-2.73l.03-.07-1.94-.52-.03.07c-.1.32-.26.64-.45.94-.17.27-.4.52-.7.74-.29.21-.64.39-1.05.51-.4.12-.9.19-1.45.19a3.8 3.8 0 0 1-2.85-1.05 4.07 4.07 0 0 1-1.09-2.7h9.68v-1.61c0-.53-.12-1.12-.34-1.74A5.03 5.03 0 0 0 88.1 15Zm-8.24 3.21a3.83 3.83 0 0 1 1.22-2.25 4.2 4.2 0 0 1 3.99-.7c.44.16.83.38 1.16.66.35.27.62.62.83 1.02.2.38.33.8.37 1.27h-7.57ZM73.65 19.42a6.11 6.11 0 0 0-3.23-1.02 6.63 6.63 0 0 1-2.68-.58c-.47-.3-.7-.7-.7-1.25 0-.27.08-.5.21-.7.14-.2.33-.38.56-.52a4.05 4.05 0 0 1 1.78-.42c.85 0 1.54.21 2.06.63.53.41.83 1 .91 1.73l.01.1 1.95-.47-.01-.07c-.07-.45-.22-.91-.45-1.36a3.46 3.46 0 0 0-.94-1.2 4.6 4.6 0 0 0-1.52-.84 6.05 6.05 0 0 0-2.1-.34c-.58 0-1.13.07-1.65.22-.52.14-1 .36-1.43.65-.41.3-.74.66-.99 1.1a2.9 2.9 0 0 0-.37 1.49v.14c0 1.06.38 1.87 1.14 2.42a6.2 6.2 0 0 0 3.24.97c1.18.08 2.04.26 2.56.54.5.28.75.72.75 1.36 0 .6-.25 1.06-.78 1.4-.52.32-1.22.48-2.1.48a3.68 3.68 0 0 1-2.46-.79 3.13 3.13 0 0 1-1.04-2.14v-.09l-1.93.46h-.02v.07a4.4 4.4 0 0 0 3.1 4c.7.24 1.52.36 2.46.36.7 0 1.35-.09 1.93-.27.6-.16 1.12-.4 1.53-.72A3.38 3.38 0 0 0 74.8 22v-.14c0-1.07-.39-1.9-1.14-2.44ZM60.25 23.4c-.1-.1-.14-.27-.14-.49v-9.46h-2.05v1.85h-.16a3.78 3.78 0 0 0-1.61-1.63 4.62 4.62 0 0 0-2.26-.56c-.77 0-1.5.14-2.19.41a5.27 5.27 0 0 0-3.02 3.12c-.29.75-.44 1.63-.44 2.6v.38c0 .99.15 1.87.44 2.63.3.75.7 1.4 1.2 1.93a5.48 5.48 0 0 0 4.05 1.57c.8 0 1.51-.2 2.2-.58.69-.38 1.24-.97 1.63-1.75h.16v.06c0 .56.18 1.03.53 1.4.37.35.85.54 1.41.54h1.36v-1.87h-.68c-.2 0-.34-.05-.43-.15Zm-4.46.13c-.46.2-.97.3-1.52.3a3.68 3.68 0 0 1-2.75-1.09 4.42 4.42 0 0 1-1.05-3.12v-.38c0-.62.1-1.2.29-1.71a3.65 3.65 0 0 1 5-2.17c.47.2.88.49 1.21.86.35.37.62.83.8 1.36.2.5.3 1.08.3 1.7v.3c0 .63-.1 1.23-.3 1.76-.18.5-.45.96-.78 1.33-.33.37-.73.66-1.2.86Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 451 B

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#37814A" d="M2.303 0A2.298 2.298 0 0 0 0 2.303v19.394A2.298 2.298 0 0 0 2.303 24h19.394A2.298 2.298 0 0 0 24 21.697V2.303A2.298 2.298 0 0 0 21.697 0zm8.177 3.072c4.098 0 7.028 1.438 7.68 1.764l-1.194 2.55c-2.442-1.057-4.993-1.41-5.672-1.41-1.574 0-2.17.922-2.17 1.763v8.494c0 .869.596 1.791 2.17 1.791.679 0 3.23-.38 5.672-1.41l1.194 2.496c-.435.271-3.637 1.818-7.68 1.818-1.112 0-4.64-.244-4.64-4.64V7.713c0-4.397 3.528-4.64 4.64-4.64z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" id="图层_1" x="0" y="0" version="1.1" viewBox="0 0 519.27 214.14"><style>.st2{fill:#a9cc54}</style><path d="M493.54 171.25c-35.63 0-64.52-28.89-64.52-64.52s28.89-64.52 64.52-64.52c3.85 0 7.62.36 11.29 1L493.54 5.45H104.46v.02C49.24 6.51 4.78 51.59 4.78 107.07c0 55.47 44.45 100.56 99.68 101.59v.02h389.07l11.29-38.44c-3.66.65-7.43 1.01-11.28 1.01" style="fill:#b6de64"/><defs><path id="SVGID_1_" d="M493.54 171.25c-35.63 0-64.52-28.89-64.52-64.52s28.89-64.52 64.52-64.52c3.85 0 7.62.36 11.29 1V5.45H104.46v.02C49.24 6.51 4.78 51.59 4.78 107.07c0 55.47 44.45 100.56 99.68 101.59v.02h389.07l11.29-38.44c-3.66.65-7.43 1.01-11.28 1.01"/></defs><clipPath id="SVGID_2_"><use xlink:href="#SVGID_1_" style="overflow:visible"/></clipPath><g style="clip-path:url(#SVGID_2_)"><path d="M-23.16 55.58H428.5v51.1H-23.16zM-23.16 157.59h530.98v72.35H-23.16z" class="st2"/></g><path d="M493.54 184.23c-43.94 0-79.56-33.62-79.56-77.56s35.62-76.56 79.56-76.56" style="fill:none;stroke:#ddf4a4;stroke-width:49;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/></svg>

Before

Width:  |  Height:  |  Size: 529 B

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -1,5 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="lucide/github">
<path id="Vector" d="M15 22V18C15.1391 16.7473 14.7799 15.4901 14 14.5C17 14.5 20 12.5 20 9C20.08 7.75 19.73 6.52 19 5.5C19.28 4.35 19.28 3.15 19 2C19 2 18 2 16 3.5C13.36 3 10.64 3 8 3.5C6 2 5 2 5 2C4.7 3.15 4.7 4.35 5 5.5C4.27187 6.51588 3.91847 7.75279 4 9C4 12.5 7 14.5 10 14.5C9.61 14.99 9.32 15.55 9.15 16.15C8.98 16.75 8.93 17.38 9 18M9 18V22M9 18C4.49 20 4 16 2 16" stroke="#7A8291" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector" d="M15 22V18C15.1391 16.7473 14.7799 15.4901 14 14.5C17 14.5 20 12.5 20 9C20.08 7.75 19.73 6.52 19 5.5C19.28 4.35 19.28 3.15 19 2C19 2 18 2 16 3.5C13.36 3 10.64 3 8 3.5C6 2 5 2 5 2C4.7 3.15 4.7 4.35 5 5.5C4.27187 6.51588 3.91847 7.75279 4 9C4 12.5 7 14.5 10 14.5C9.61 14.99 9.32 15.55 9.15 16.15C8.98 16.75 8.93 17.38 9 18M9 18V22M9 18C4.49 20 4 16 2 16" stroke="#C0C1C3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 587 B

After

Width:  |  Height:  |  Size: 587 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#A68400" d="m0 0 11.955 24L24 0zm13.366 4.827h1.393v1.38h-1.393zm-2.77 5.569H9.22V8.993h1.389zm0-2.087H9.22V6.906h1.389zm0-2.086H9.22V4.819h1.389zm2.087 6.263h-1.377V11.08h1.388zm0-2.09h-1.377V8.993h1.388zm0-2.087h-1.377V6.906h1.388zm0-2.086h-1.377V4.819h1.388zm.683.683h1.393v1.389h-1.393zm0 3.475V8.993h1.389v1.388Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#FFEC6E" d="m0 0 11.955 24L24 0zm13.366 4.827h1.393v1.38h-1.393zm-2.77 5.569H9.22V8.993h1.389zm0-2.087H9.22V6.906h1.389zm0-2.086H9.22V4.819h1.389zm2.087 6.263h-1.377V11.08h1.388zm0-2.09h-1.377V8.993h1.388zm0-2.087h-1.377V6.906h1.388zm0-2.086h-1.377V4.819h1.388zm.683.683h1.393v1.389h-1.393zm0 3.475V8.993h1.389v1.388Z"/></svg>

Before

Width:  |  Height:  |  Size: 398 B

After

Width:  |  Height:  |  Size: 398 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="285" height="285" preserveAspectRatio="xMidYMid" viewBox="-14.5 0 285 285"><path fill="#7C4DD6" d="M230.4 0c13.996 0 25.37 11.232 25.597 25.177l.003.423v233.244c0 13.996-11.235 25.37-25.177 25.597l-.423.003H25.6c-13.996 0-25.37-11.235-25.597-25.176L0 258.844V25.6C0 11.601 11.235.23 25.177.003L25.6 0zm0 14.222H25.6c-6.174 0-11.218 4.948-11.374 11.085l-.004.293v233.244c0 6.174 4.948 11.218 11.085 11.374l.293.004h204.8c6.177 0 11.218-4.947 11.374-11.084l.004-.294V25.6c0-6.272-5.102-11.378-11.378-11.378M64 184.89l32 28.444-32 28.445zM92.444 42.667v80.75c14.201-4.622 34.052-9.64 53.334-9.64 17.582 0 28.106 6.913 33.838 12.712 12.109 12.25 12.397 27.82 12.381 29.897v.058l.003 85.334h-28.444v-84.942c-.14-6.639-3.34-14.614-17.778-14.614-28.632 0-60.714 14.257-61.666 14.683l-.02.009L64 166.016V42.666zm99.556 0c-1.92 16.156-8.47 31.644-21.333 46.222h-28.445c11.179-14.667 18.198-30.101 21.334-46.222Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="285" height="285" preserveAspectRatio="xMidYMid" viewBox="-14.5 0 285 285"><path fill="#430098" d="M230.4 0c13.996 0 25.37 11.232 25.597 25.177l.003.423v233.244c0 13.996-11.235 25.37-25.177 25.597l-.423.003H25.6c-13.996 0-25.37-11.235-25.597-25.176L0 258.844V25.6C0 11.601 11.235.23 25.177.003L25.6 0zm0 14.222H25.6c-6.174 0-11.218 4.948-11.374 11.085l-.004.293v233.244c0 6.174 4.948 11.218 11.085 11.374l.293.004h204.8c6.177 0 11.218-4.947 11.374-11.084l.004-.294V25.6c0-6.272-5.102-11.378-11.378-11.378M64 184.89l32 28.444-32 28.445zM92.444 42.667v80.75c14.201-4.622 34.052-9.64 53.334-9.64 17.582 0 28.106 6.913 33.838 12.712 12.109 12.25 12.397 27.82 12.381 29.897v.058l.003 85.334h-28.444v-84.942c-.14-6.639-3.34-14.614-17.778-14.614-28.632 0-60.714 14.257-61.666 14.683l-.02.009L64 166.016V42.666zm99.556 0c-1.92 16.156-8.47 31.644-21.333 46.222h-28.445c11.179-14.667 18.198-30.101 21.334-46.222Z"/></svg>

Before

Width:  |  Height:  |  Size: 958 B

After

Width:  |  Height:  |  Size: 958 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 463 B

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -1 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M14 10h-4v4h4v-4zM18 6h-4v4.001h4v-4zM18 14h-4v4h4v-4zM22 2h-4v4h4V2zM22 18h-4v4h4v-4z" fill="#1FD5F9"></path><path d="M6 18V2H2v20h12v-4H6z" fill="#7A8291"></path></svg>
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LiveKit</title><path d="M14 10h-4v4h4v-4zM18 6h-4v4.001h4v-4zM18 14h-4v4h4v-4zM22 2h-4v4h4V2zM22 18h-4v4h4v-4z" fill="#1FD5F9"></path><path d="M6 18V2H2v20h12v-4H6z" fill="#fff"></path></svg>

Before

Width:  |  Height:  |  Size: 251 B

After

Width:  |  Height:  |  Size: 316 B

View File

@@ -1 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#7A8291" d="M22 24H2V0h20zM17 4.8H7v14.4h10z"/></svg>
<svg width='234' height='42' viewBox='0 0 234 42' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M18 30H6V18H18V30Z' fill='#CFCECD'/><path d='M18 12H6V30H18V12ZM24 36H0V6H24V36Z' fill='#656363'/><path d='M48 30H36V18H48V30Z' fill='#CFCECD'/><path d='M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z' fill='#656363'/><path d='M84 24V30H66V24H84Z' fill='#CFCECD'/><path d='M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z' fill='#656363'/><path d='M108 36H96V18H108V36Z' fill='#CFCECD'/><path d='M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z' fill='#656363'/><path d='M144 30H126V18H144V30Z' fill='#CFCECD'/><path d='M144 12H126V30H144V36H120V6H144V12Z' fill='#211E1E'/><path d='M168 30H156V18H168V30Z' fill='#CFCECD'/><path d='M168 12H156V30H168V12ZM174 36H150V6H174V36Z' fill='#211E1E'/><path d='M198 30H186V18H198V30Z' fill='#CFCECD'/><path d='M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z' fill='#211E1E'/><path d='M234 24V30H216V24H234Z' fill='#CFCECD'/><path d='M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z' fill='#211E1E'/></svg>

Before

Width:  |  Height:  |  Size: 137 B

After

Width:  |  Height:  |  Size: 1.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

View File

@@ -1 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#7A8291" d="M18.263.007c-3.121-.147-5.744 2.109-6.192 5.082-.018.138-.045.272-.067.405-.696 3.703-3.936 6.507-7.827 6.507-1.388 0-2.691-.356-3.825-.979a.2024.2024 0 0 0-.302.178V24H12v-8.999c0-1.656 1.338-3 2.987-3h2.988c3.382 0 6.103-2.817 5.97-6.244-.12-3.084-2.61-5.603-5.682-5.75"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Render</title><path d="M18.263.007c-3.121-.147-5.744 2.109-6.192 5.082-.018.138-.045.272-.067.405-.696 3.703-3.936 6.507-7.827 6.507a7.9 7.9 0 0 1-3.825-.979.202.202 0 0 0-.302.178V24H12v-8.999c0-1.656 1.338-3 2.987-3h2.988c3.382 0 6.103-2.817 5.97-6.244-.12-3.084-2.61-5.603-5.682-5.75"/></svg>

Before

Width:  |  Height:  |  Size: 376 B

After

Width:  |  Height:  |  Size: 362 B

View File

@@ -1 +1 @@
<svg role="img" viewBox="89.7 190 220 220" xmlns="http://www.w3.org/2000/svg"><linearGradient id="a" x1="128.046" x2="313.226" y1="579.207" y2="472.293" gradientTransform="translate(-20.96 -225.764)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#10e7ff"/><stop offset=".103" style="stop-color:#33d5ff"/><stop offset=".255" style="stop-color:#60bdff"/><stop offset=".408" style="stop-color:#86aaff"/><stop offset=".56" style="stop-color:#a39aff"/><stop offset=".71" style="stop-color:#b790ff"/><stop offset=".858" style="stop-color:#c489ff"/><stop offset="1" style="stop-color:#c887ff"/></linearGradient><path d="m199.7 408.2-92.2-54.1V245.9l92.2-54.1 92.2 54.1v108.2zm-79.4-61.7 79.1 46.5 79.1-46.5v-92.9l-79-46.5-79.3 46.5c.1-.1.1 92.9.1 92.9" style="fill:url(#a)"/><linearGradient id="b" x1="185.354" x2="277.202" y1="542.013" y2="488.984" gradientTransform="translate(-20.96 -225.764)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#10e7ff"/><stop offset=".103" style="stop-color:#33d5ff"/><stop offset=".255" style="stop-color:#60bdff"/><stop offset=".408" style="stop-color:#86aaff"/><stop offset=".56" style="stop-color:#a39aff"/><stop offset=".71" style="stop-color:#b790ff"/><stop offset=".858" style="stop-color:#c489ff"/><stop offset="1" style="stop-color:#c887ff"/></linearGradient><path d="m239.4 270.6-32.3 56.8 10.5 18.5 42.8-75.3zm-36.1 0L171 327.4l10.5 18.5 42.8-75.3zm-64.4 0 42.8 75.3 10.5-18.5-32.3-56.8zm0 0 42.8 75.3 10.5-18.5-32.3-56.8zm64.4 0L171 327.4l10.5 18.5 42.8-75.3z" style="fill:url(#b)"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" version="1.0" viewBox="0 0 800 600"><path d="M364.7 246.1h16.9L363.2 300h-16.9l-18.5-53.9h17.1l10.2 33h.2zM436 300h-39.4v-53.9h39.2v13.6h-23.6v7.1h20.9V279h-20.9v7.4H436zm41.6-54.8c11.3 0 19.6 5.8 22.1 15.1l-14.7 6.4c-.7-4.2-2.9-7.4-7.6-7.4-5.3 0-9.3 4.4-9.3 14s3.8 14.2 9.3 14.2c4.5 0 6.9-3.4 7.6-7.6l14.7 6.4c-2.5 9.3-10.9 15.1-22.1 15.1-16.2 0-26.1-10.7-26.1-28s9.9-28.2 26.1-28.2m50.3 54.8v-40.3h-16.2v-13.6h48.5v13.6H544V300zm97.7-26.9c0 17.2-10 28-26.1 28-16 0-26.1-10.7-26.1-28s10.2-28 26.1-28c16.1.1 26.1 10.8 26.1 28m-35.4 0c0 8.7 3.3 14.2 9.4 14.2s9.4-5.3 9.4-14.2c0-8.7-3.3-14.2-9.4-14.2s-9.4 5.3-9.4 14.2m84.4 26.9c-1.5-1.8-2-3.4-2.5-6.4l-1.1-6c-.7-3.8-2.5-4.9-5.8-4.9h-6.4V300h-16v-53.9h25.6c12.2 0 20.7 4.7 20.7 16.5 0 7.8-5.3 12.3-11.1 13.4v.2c4.7.7 7.4 3.6 8.5 8.5l2 8.5c.7 2.9 1.6 4.7 3.4 6.7.1.1-17.3.1-17.3.1m-8.2-40.7h-7.8V270h7.8c3.8 0 5.8-1.6 5.8-5.4 0-3.5-1.8-5.3-5.8-5.3m-321.5 81.2c3.3.4 5.6 2.4 5.6 6 0 4.4-2.7 7.3-8.7 7.3h-9.3v-26.1h8.9c5.1 0 8.2 2.4 8.2 6.9 0 3.1-2 5.3-4.7 5.9m-9.1-1.1h5.4c3.3 0 5.1-1.5 5.1-4.5s-1.8-4.5-5.1-4.5h-5.4zm0 11.6h5.8c3.6 0 5.4-1.6 5.4-4.5s-2-4.5-5.4-4.5h-5.8zm28-10.7 7.1-12.7h3.4l-8.9 15.8v10.2h-3.3v-10.2l-9.1-15.8h3.6zm26.7-12.7h7.6c7.3 0 12.7 4 12.7 12.9s-5.4 13.1-12.7 13.1h-7.6zm3.2 23.2h4.4c4.7 0 9.3-2.5 9.3-10.3s-4.5-10.2-9.3-10.2h-4.4zm36.2-4.3H419l-2.5 7.3h-3.3l9.4-26.1h3.6l9.4 26.1h-3.3zm-10-2.9h8.9l-4.4-12.5zm24.1 10.1v-23.2h-8.7v-2.9h20.9v2.9h-8.7v23.2zm28.3-7.2h-10.9l-2.5 7.3h-3.3l9.4-26.1h3.6l9.4 26.1h-3.3zm-9.8-2.9h8.9l-4.4-12.5zm20.7-16h7.6c7.3 0 12.7 4 12.7 12.9s-5.4 13.1-12.7 13.1h-7.6zm3.3 23.2h4.4c4.7 0 9.3-2.5 9.3-10.3s-4.5-10.2-9.3-10.2h-4.4zm44.1-10.1c0 8.4-4.5 13.4-11.3 13.4S508 349 508 340.7c0-8.4 4.5-13.4 11.3-13.4s11.3 5 11.3 13.4m-19 0c0 6.4 2.7 10.7 7.8 10.7s7.8-4.4 7.8-10.7c0-6.4-2.7-10.7-7.8-10.7s-7.8 4.3-7.8 10.7m45-.2v13.1H554l-.5-3.8c-.9 2.7-3.8 4.4-7.3 4.4-6.5 0-11.1-4.9-11.1-13.4 0-8.7 4.9-13.6 11.4-13.6 4.7 0 8 2 9.4 6l-2.9 1.6c-.9-3.1-3.4-4.9-6.7-4.9-5.1 0-8 4-8 10.7 0 6.4 2.5 10.7 8 10.7 3.4 0 6.7-1.8 6.7-6.5V343h-6.4v-2.7h9.8z"/><linearGradient id="a" x1="128.046" x2="313.226" y1="579.207" y2="472.293" gradientTransform="translate(-20.96 -225.764)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#10e7ff"/><stop offset=".103" style="stop-color:#33d5ff"/><stop offset=".255" style="stop-color:#60bdff"/><stop offset=".408" style="stop-color:#86aaff"/><stop offset=".56" style="stop-color:#a39aff"/><stop offset=".71" style="stop-color:#b790ff"/><stop offset=".858" style="stop-color:#c489ff"/><stop offset="1" style="stop-color:#c887ff"/></linearGradient><path d="m199.7 408.2-92.2-54.1V245.9l92.2-54.1 92.2 54.1v108.2zm-79.4-61.7 79.1 46.5 79.1-46.5v-92.9l-79-46.5-79.3 46.5c.1-.1.1 92.9.1 92.9" style="fill:url(#a)"/><linearGradient id="b" x1="185.354" x2="277.202" y1="542.013" y2="488.984" gradientTransform="translate(-20.96 -225.764)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#10e7ff"/><stop offset=".103" style="stop-color:#33d5ff"/><stop offset=".255" style="stop-color:#60bdff"/><stop offset=".408" style="stop-color:#86aaff"/><stop offset=".56" style="stop-color:#a39aff"/><stop offset=".71" style="stop-color:#b790ff"/><stop offset=".858" style="stop-color:#c489ff"/><stop offset="1" style="stop-color:#c887ff"/></linearGradient><path d="m239.4 270.6-32.3 56.8 10.5 18.5 42.8-75.3zm-36.1 0L171 327.4l10.5 18.5 42.8-75.3zm-64.4 0 42.8 75.3 10.5-18.5-32.3-56.8zm0 0 42.8 75.3 10.5-18.5-32.3-56.8zm64.4 0L171 327.4l10.5 18.5 42.8-75.3z" style="fill:url(#b)"/></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -560,7 +560,7 @@ const onboardingConfigWithLinks = [
key: 'springboot',
label: 'Spring Boot',
imgUrl: springbootUrl,
link: '/docs/instrumentation/java/opentelemetry-java/',
link: '/docs/instrumentation/opentelemetry-springboot/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -570,25 +570,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/java/opentelemetry-java/',
link: '/docs/instrumentation/opentelemetry-springboot/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/java/opentelemetry-java/',
link: '/docs/instrumentation/opentelemetry-springboot/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/java/opentelemetry-java/',
link: '/docs/instrumentation/opentelemetry-springboot/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/java/opentelemetry-java/',
link: '/docs/instrumentation/opentelemetry-springboot/',
},
],
},
@@ -597,7 +597,7 @@ const onboardingConfigWithLinks = [
key: 'tomcat',
label: 'Tomcat',
imgUrl: tomcatUrl,
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
link: '/docs/instrumentation/opentelemetry-tomcat/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -607,25 +607,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
link: '/docs/instrumentation/opentelemetry-tomcat/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
link: '/docs/instrumentation/opentelemetry-tomcat/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
link: '/docs/instrumentation/opentelemetry-tomcat/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
link: '/docs/instrumentation/opentelemetry-tomcat/',
},
],
},
@@ -634,7 +634,7 @@ const onboardingConfigWithLinks = [
key: 'jboss',
label: 'JBoss/WildFly',
imgUrl: jbossUrl,
link: '/docs/instrumentation/java/opentelemetry-jboss/',
link: '/docs/instrumentation/opentelemetry-jboss/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -644,25 +644,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/java/opentelemetry-jboss/',
link: '/docs/instrumentation/opentelemetry-jboss/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/java/opentelemetry-jboss/',
link: '/docs/instrumentation/opentelemetry-jboss/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/java/opentelemetry-jboss/',
link: '/docs/instrumentation/opentelemetry-jboss/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/java/opentelemetry-jboss/',
link: '/docs/instrumentation/opentelemetry-jboss/',
},
],
},
@@ -671,7 +671,7 @@ const onboardingConfigWithLinks = [
key: 'opentelemetry-quarkus',
label: 'Quarkus',
imgUrl: quarkusUrl,
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
link: '/docs/instrumentation/opentelemetry-quarkus/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -681,25 +681,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
link: '/docs/instrumentation/opentelemetry-quarkus/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
link: '/docs/instrumentation/opentelemetry-quarkus/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
link: '/docs/instrumentation/opentelemetry-quarkus/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
link: '/docs/instrumentation/opentelemetry-quarkus/',
},
],
},
@@ -708,7 +708,7 @@ const onboardingConfigWithLinks = [
key: 'Others',
label: 'Others',
imgUrl: javaOthersUrl,
link: '/docs/instrumentation/java/opentelemetry-java/',
link: '/docs/instrumentation/opentelemetry-java/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -718,25 +718,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/java/opentelemetry-java/',
link: '/docs/instrumentation/opentelemetry-java/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/java/opentelemetry-java/',
link: '/docs/instrumentation/opentelemetry-java/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/java/opentelemetry-java/',
link: '/docs/instrumentation/opentelemetry-java/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/java/opentelemetry-java/',
link: '/docs/instrumentation/opentelemetry-java/',
},
],
},
@@ -872,7 +872,7 @@ const onboardingConfigWithLinks = [
key: 'nodejs-nestjs',
label: 'NodeJs/NestJS',
imgUrl: nodejsUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
link: '/docs/instrumentation/opentelemetry-javascript/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -882,25 +882,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
link: '/docs/instrumentation/opentelemetry-javascript/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
link: '/docs/instrumentation/opentelemetry-javascript/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
link: '/docs/instrumentation/opentelemetry-javascript/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
link: '/docs/instrumentation/opentelemetry-javascript/',
},
],
},
@@ -909,7 +909,7 @@ const onboardingConfigWithLinks = [
key: 'nextjs',
label: 'NextJS',
imgUrl: nextjsUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
link: '/docs/instrumentation/opentelemetry-nextjs/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -919,25 +919,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
link: '/docs/instrumentation/opentelemetry-nextjs/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
link: '/docs/instrumentation/opentelemetry-nextjs/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
link: '/docs/instrumentation/opentelemetry-nextjs/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
link: '/docs/instrumentation/opentelemetry-nextjs/',
},
],
},
@@ -946,7 +946,7 @@ const onboardingConfigWithLinks = [
key: 'nuxtjs',
label: 'NuxtJS',
imgUrl: nuxtjsUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -956,25 +956,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
},
],
},
@@ -983,7 +983,7 @@ const onboardingConfigWithLinks = [
key: 'react-native',
label: 'React Native',
imgUrl: reactjsUrl,
link: '/docs/instrumentation/javascript/opentelemetry-react-native/',
link: '/docs/instrumentation/opentelemetry-react-native/',
},
],
},
@@ -1298,7 +1298,7 @@ const onboardingConfigWithLinks = [
'resque',
],
id: 'ruby',
link: '/docs/instrumentation/opentelemetry-ruby/',
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -1308,25 +1308,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/opentelemetry-ruby/',
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/opentelemetry-ruby/',
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/opentelemetry-ruby/',
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/opentelemetry-ruby/',
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
},
],
},
@@ -2292,7 +2292,7 @@ const onboardingConfigWithLinks = [
'python structured logging',
'python-logs-auto-instrumentation',
],
link: '/docs/logs-management/send-logs/python-logs/',
link: '/docs/userguide/python-logs-auto-instrumentation/',
},
{
dataSource: 'otel-java-sdk',
@@ -2323,7 +2323,7 @@ const onboardingConfigWithLinks = [
'Logback',
'log4j',
],
link: '/docs/logs-management/send-logs/java-logs/',
link: '/docs/userguide/collecting_application_logs_otel_sdk_java/',
},
{
dataSource: 'nodejs-logs',
@@ -2463,7 +2463,7 @@ const onboardingConfigWithLinks = [
'linux',
],
imgUrl: hostmetricsUrl,
link: '/docs/infrastructure-monitoring/user-guides/hostmetrics/',
link: '/docs/userguide/hostmetrics/',
},
{
dataSource: 'k8s-metrics',
@@ -2495,7 +2495,7 @@ const onboardingConfigWithLinks = [
'telemetry',
],
imgUrl: kubernetesUrl,
link: '/docs/infrastructure-monitoring/user-guides/k8s-metrics/',
link: '/docs/userguide/k8s-metrics/',
},
{
dataSource: 'docker-container-metrics',
@@ -2583,7 +2583,7 @@ const onboardingConfigWithLinks = [
key: 'aws-ec2-metrics-manual',
label: 'Manual Setup',
imgUrl: ec2Url,
link: '/docs/opentelemetry-collection-agents/vm/install/',
link: '/docs/tutorial/opentelemetry-binary-usage-in-virtual-machine/',
},
],
},
@@ -2772,7 +2772,7 @@ const onboardingConfigWithLinks = [
'vpc-logs',
],
imgUrl: vpcUrl,
link: '/docs/aws-monitoring/vpc/',
link: '/docs/aws-monitoring/vpc-logs/',
},
{
dataSource: 'aws-rds',
@@ -3837,7 +3837,7 @@ const onboardingConfigWithLinks = [
'traces',
'tracing',
],
link: '/docs/openai-monitoring/',
link: '/docs/llm/opentelemetry-openai-monitoring/',
},
{
dataSource: 'codex-monitoring',
@@ -4779,7 +4779,7 @@ const onboardingConfigWithLinks = [
link: '/docs/instrumentation/mobile-instrumentation/opentelemetry-flutter/',
},
{
dataSource: 'ios-swift',
dataSource: 'swift',
label: 'iOS Swift',
imgUrl: swiftMonitoringUrl,
tags: ['mobile app monitoring'],
@@ -4808,7 +4808,6 @@ const onboardingConfigWithLinks = [
'traces',
'tracing',
],
id: 'ios-swift',
link: '/docs/instrumentation/mobile-instrumentation/opentelemetry-swiftui/',
},
{
@@ -5474,7 +5473,7 @@ const onboardingConfigWithLinks = [
'telemetry',
],
id: 'hasura',
link: '/docs/integrations/outposts/hasura/',
link: '/docs/integrations/opentelemetry-hasura/',
},
{
dataSource: 'opencode-observability',
@@ -5936,7 +5935,7 @@ const onboardingConfigWithLinks = [
'metrics',
'monitoring',
],
link: '/docs/integrations/outposts/flyio/',
link: '/docs/integrations/flyio/',
},
{
dataSource: 'envoy',
@@ -6514,7 +6513,7 @@ const onboardingConfigWithLinks = [
'tracing',
],
id: 'openclaw-observability',
link: '/docs/openclaw-observability/',
link: '/docs/openclaw-monitoring/',
},
{
dataSource: 'claude-agent-monitoring',
@@ -6556,7 +6555,7 @@ const onboardingConfigWithLinks = [
'render monitoring',
],
id: 'render-metrics',
link: '/docs/integrations/outposts/render/',
link: '/docs/metrics-management/render-metrics/',
},
{
dataSource: 'cert-manager',
@@ -6580,7 +6579,7 @@ const onboardingConfigWithLinks = [
'tls',
],
id: 'cert-manager',
link: '/docs/infrastructure-monitoring/user-guides/cert-manager/',
link: '/docs/infrastructure-monitoring/cert-manager/',
},
{
dataSource: 'pgbouncer',

View File

@@ -1,4 +1,3 @@
import type { ExtendedSeries } from 'lib/uPlotV2/config/types';
import { Widgets } from 'types/api/widgets/widget';
import {
MetricRangePayloadProps,
@@ -165,27 +164,6 @@ describe('BarPanel utils', () => {
expect(config.series).toHaveLength(3);
});
it('passes partial timestamps to the series', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [
[1000, '1'],
[1060, '0.2'],
],
partialTimestamps: [1060],
} as MetricRangePayloadProps['data']['result'][0],
]);
const series = prepareBarPanelConfig({
...baseParams,
apiResponse,
}).getConfig().series?.[1] as ExtendedSeries;
expect(series.partialTimestamps).toStrictEqual(new Set([1060]));
});
it('uses getLegend for label when currentQuery is provided', () => {
const apiResponse = createApiResponse([
{

View File

@@ -89,9 +89,6 @@ export function prepareBarPanelConfig({
isDarkMode,
stepInterval: currentStepInterval,
metric: series.metric,
partialTimestamps: series.partialTimestamps
? new Set(series.partialTimestamps)
: undefined,
});
});

View File

@@ -5,7 +5,6 @@ import {
} from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import type { ExtendedSeries } from 'lib/uPlotV2/config/types';
import { PanelMode } from 'lib/visualization/panels/types';
import { prepareUPlotConfig } from 'container/WidgetCard/Panels/TimeSeriesPanel/utils';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
@@ -186,28 +185,6 @@ describe('TimeSeriesPanel utils', () => {
});
});
it('passes partial timestamps to the series', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [
[1000, '1'],
[1060, '0.2'],
],
partialTimestamps: [1060],
} as MetricRangePayloadProps['data']['result'][0],
]);
const series = prepareUPlotConfig({
...baseParams,
apiResponse,
currentQuery: null as unknown as Query,
}).getConfig().series?.[1] as ExtendedSeries;
expect(series.partialTimestamps).toStrictEqual(new Set([1060]));
});
it('uses getLegend for label when currentQuery is provided', () => {
const apiResponse = createApiResponse([
{

View File

@@ -101,9 +101,6 @@ export const prepareUPlotConfig = ({
fillMode: widget.fillMode || FillMode.None,
isDarkMode,
metric: series.metric,
partialTimestamps: series.partialTimestamps
? new Set(series.partialTimestamps)
: undefined,
});
});

View File

@@ -73,4 +73,21 @@ describe('KeyboardHotkeysProvider', () => {
expect(handleShortcut).not.toHaveBeenCalled();
});
it('ignores keydown events dispatched on document itself', () => {
const handleShortcut = jest.fn();
render(
<KeyboardHotkeysProvider>
<TestComponentWithRegister handleShortcut={handleShortcut} />
</KeyboardHotkeysProvider>,
);
expect(() =>
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })),
).not.toThrow();
document.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' }));
expect(handleShortcut).not.toHaveBeenCalled();
});
});

View File

@@ -79,11 +79,11 @@ export function KeyboardHotkeysProvider({
const wasExtended = useRef(false);
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.repeat) {
const { target } = event;
if (event.repeat || !(target instanceof Element)) {
return;
}
const target = event.target as HTMLElement;
const isCodeMirrorEditor = target.closest('.cm-editor') !== null;
const isMonacoEditor = target.closest('.monaco-editor') !== null;
if (

View File

@@ -1,42 +0,0 @@
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
import { convertNewDataToOld } from '../convertNewDataToOld';
describe('convertNewDataToOld', () => {
it('carries partial values as epoch-second partialTimestamps', () => {
const payload = {
data: {
resultType: 'time_series',
result: [
{
queryName: 'A',
series: [
{
labels: {},
labelsArray: [],
values: [
{ timestamp: 1_000_000, value: '1' },
{ timestamp: 1_060_000, value: '0.2', partial: true },
],
},
{
labels: {},
labelsArray: [],
values: [{ timestamp: 1_000_000, value: '1' }],
},
],
},
],
},
} as unknown as MetricRangePayloadV3;
const [partial, complete] = convertNewDataToOld(payload).data.result;
expect(partial.values).toStrictEqual([
[1000, '1'],
[1060, '0.2'],
]);
expect(partial.partialTimestamps).toStrictEqual([1060]);
expect(complete).not.toHaveProperty('partialTimestamps');
});
});

View File

@@ -25,16 +25,11 @@ export const convertNewDataToOld = (
return [...acc, renderValues];
}, []);
const partialTimestamps = series.values
.filter((value) => value.partial)
.map((value) => value.timestamp / 1000);
const result: QueryData = {
metric: series.labels,
values,
queryName: `${item.queryName}`,
metaData: series.metaData,
...(partialTimestamps.length ? { partialTimestamps } : {}),
};
oldResult.push(result);

View File

@@ -122,27 +122,6 @@ describe('Tooltip', () => {
expect(screen.getByText(expectedTitle)).toBeInTheDocument();
});
it('marks the header partial when a visible series has a partial bucket at the cursor', () => {
const withSeries = (series: unknown[]): uPlot =>
Object.assign(createUPlotInstance(0), { series });
const partial = { show: true, partialTimestamps: new Set([1]) };
const { unmount } = renderTooltip({
uPlotInstance: withSeries([{}, partial]),
});
expect(screen.getByTestId('uplot-tooltip-header-partial')).toHaveTextContent(
'(partial)',
);
unmount();
renderTooltip({
uPlotInstance: withSeries([{}, { ...partial, show: false }]),
});
expect(
screen.queryByTestId('uplot-tooltip-header-partial'),
).not.toBeInTheDocument();
});
it('drops the date from the header title for a point on the current day', () => {
const todayTimestamp = dayjs().tz('UTC').startOf('hour').unix();
const uPlotInstance = createUPlotInstance(0, todayTimestamp);

View File

@@ -13,17 +13,6 @@
gap: var(--spacing-2);
}
.headerEnd {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.partialLabel {
font-style: italic;
opacity: 0.7;
}
.pinnedItem {
padding: var(--spacing-4);
}

View File

@@ -5,7 +5,6 @@ import { useTimezone } from 'providers/Timezone';
import type uPlot from 'uplot';
import { formatTimestampOmittingTodaysDate } from 'utils/timeUtils';
import type { ExtendedSeries } from '../../../../config/types';
import { TooltipContentItem } from '../../../types';
import TooltipItem from '../TooltipItem/TooltipItem';
import TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
@@ -35,7 +34,7 @@ export default function TooltipHeader({
const { timezone: userTimezone } = useTimezone();
const resolvedTimezone = timezone?.value ?? userTimezone.value;
const header = useMemo(() => {
const headerTitle = useMemo(() => {
if (!showTooltipHeader) {
return null;
}
@@ -47,21 +46,14 @@ export default function TooltipHeader({
if (timestamp == null) {
return null;
}
return {
title: formatTimestampOmittingTodaysDate(
timestamp * 1000,
resolvedTimezone,
dateFormat,
),
isPartial: (uPlotInstance.series ?? []).some(
(series: ExtendedSeries) =>
series.show && series.partialTimestamps?.has(timestamp),
),
};
return formatTimestampOmittingTodaysDate(
timestamp * 1000,
resolvedTimezone,
dateFormat,
);
}, [
resolvedTimezone,
uPlotInstance.data,
uPlotInstance.series,
uPlotInstance.cursor.idx,
showTooltipHeader,
dateFormat,
@@ -72,22 +64,10 @@ export default function TooltipHeader({
className={Styles.headerContainer}
data-testid="uplot-tooltip-header-container"
>
{showTooltipHeader && header && (
{showTooltipHeader && headerTitle && (
<div className={cx(Styles.headerRow, headerRowClassName)}>
<span>{header.title}</span>
{(header.isPartial || isPinned) && (
<span className={Styles.headerEnd}>
{header.isPartial && (
<span
className={Styles.partialLabel}
data-testid="uplot-tooltip-header-partial"
>
(partial)
</span>
)}
{isPinned && <TooltipPinnedBadge />}
</span>
)}
<span>{headerTitle}</span>
{isPinned && <TooltipPinnedBadge />}
</div>
)}

View File

@@ -1,6 +1,5 @@
import { SeriesVisibilityItem } from 'lib/visualization/panels/types';
import { getStoredSeriesVisibility } from 'lib/visualization/panels/utils/legendVisibilityUtils';
import { partialPointsDrawHook } from 'lib/uPlotV2/hooks/partialPointsDrawHook';
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
import { thresholdsDrawHook } from 'lib/uPlotV2/hooks/useThresholdsDrawHook';
import { resolveSeriesVisibility } from 'lib/uPlotV2/utils/seriesVisibility';
@@ -82,8 +81,6 @@ export class UPlotConfigBuilder extends ConfigBuilder<
private thresholds: Record<string, ThresholdsDrawHookOptions> = {};
private hasPartialPointsHook = false;
private tzDate: ((timestamp: number) => Date) | undefined;
private id: string;
@@ -180,10 +177,6 @@ export class UPlotConfigBuilder extends ConfigBuilder<
*/
addSeries(props: SeriesProps): void {
this.series.push(new UPlotSeriesBuilder(props));
if (props.partialTimestamps?.size && !this.hasPartialPointsHook) {
this.hasPartialPointsHook = true;
this.addHook('draw', partialPointsDrawHook);
}
}
getSeriesSpanGapsOptions(): SeriesSpanGapsOption[] {

View File

@@ -4,7 +4,6 @@ import { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils';
import uPlot, { Series } from 'uplot';
import { resolveFillOpacity, toAlphaHex } from '../utils/fillOpacity';
import { reportBarRect } from '../utils/barRect';
import { generateGradientFill } from '../utils/generateGradientFill';
import { isolatedPointFilter } from '../utils/seriesPointsFilter';
import {
@@ -219,14 +218,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
}
getConfig(): ExtendedSeries {
const {
scaleKey,
label,
spanGaps,
show = true,
metric,
partialTimestamps,
} = this.props;
const { scaleKey, label, spanGaps, show = true, metric } = this.props;
const resolvedLineColor = this.getLineColor();
@@ -254,7 +246,6 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
...pathConfig,
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
metric,
...(partialTimestamps?.size ? { partialTimestamps } : {}),
};
}
}
@@ -367,7 +358,6 @@ function getBarPathBuilder({
builders[barsCfgKey] = barsPathBuilderFactory({
size: [barWidthFactor, effectiveBarMaxWidth],
align: barAlignment,
each: reportBarRect,
});
}
@@ -382,7 +372,6 @@ function getBarPathBuilder({
builders[barsCfgKey] = barsPathBuilderFactory({
size: [barWidthFactor, barMaxWidth],
align: barAlignment,
each: reportBarRect,
});
}

View File

@@ -650,20 +650,4 @@ describe('UPlotConfigBuilder stacking', () => {
expect(builder.getConfig().bands).toStrictEqual([{ series: [1, 3] }]);
});
it('registers the partial points draw hook once, only for series with partial timestamps', () => {
const props: SeriesProps = {
scaleKey: 'y',
label: 'Requests',
colorMapping: {},
drawStyle: DrawStyle.Line,
};
const builder = new UPlotConfigBuilder({ id: 'partial' });
builder.addSeries(props);
expect(builder.getConfig().hooks?.draw ?? []).toHaveLength(0);
builder.addSeries({ ...props, partialTimestamps: new Set([40]) });
builder.addSeries({ ...props, partialTimestamps: new Set([40]) });
expect(builder.getConfig().hooks?.draw).toHaveLength(1);
});
});

View File

@@ -1,7 +1,6 @@
import { themeColors } from 'constants/theme';
import uPlot from 'uplot';
import { reportBarRect } from '../../utils/barRect';
import { isolatedPointFilter } from '../../utils/seriesPointsFilter';
import type { SeriesProps } from '../types';
import { DrawStyle, FillMode, LineInterpolation, LineStyle } from '../types';
@@ -399,29 +398,4 @@ describe('UPlotSeriesBuilder', () => {
expect(builder.getConfig().fill).toBeUndefined();
});
it('exposes partial timestamps only when there are some', () => {
const partialTimestamps = new Set([40]);
expect(
new UPlotSeriesBuilder(createBaseProps({ partialTimestamps })).getConfig(),
).toHaveProperty('partialTimestamps', partialTimestamps);
expect(
new UPlotSeriesBuilder(
createBaseProps({ partialTimestamps: new Set() }),
).getConfig(),
).not.toHaveProperty('partialTimestamps');
});
it('reports bar rects from bar path builders', () => {
const config = new UPlotSeriesBuilder(
createBaseProps({ drawStyle: DrawStyle.Bar, barMaxWidth: 321 }),
).getConfig();
config.paths?.({} as uPlot, 1, 0, 1);
expect(uPlot.paths.bars).toHaveBeenCalledWith(
expect.objectContaining({ each: reportBarRect }),
);
});
});

View File

@@ -201,7 +201,6 @@ export enum FillMode {
export type ExtendedSeries = Series & {
metric?: { [key: string]: string };
partialTimestamps?: Set<number>;
};
export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
@@ -230,8 +229,6 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
isDarkMode?: boolean;
stepInterval?: number;
metric?: { [key: string]: string };
/** Epoch-second x values whose bucket doesn't cover a full step; marked with a dotted ring. */
partialTimestamps?: Set<number>;
}
export interface LegendItem {

View File

@@ -1,77 +0,0 @@
import uPlot from 'uplot';
import type { ExtendedSeries } from '../../config/types';
import { reportBarRect } from '../../utils/barRect';
import { partialPointsDrawHook } from '../partialPointsDrawHook';
function createPlot(
series: Partial<ExtendedSeries>,
values: (number | null)[] = [1, 2, 3],
): { u: uPlot; ctx: Record<string, jest.Mock> } {
const ctx = {
save: jest.fn(),
restore: jest.fn(),
beginPath: jest.fn(),
rect: jest.fn(),
clip: jest.fn(),
arc: jest.fn(),
fill: jest.fn(),
stroke: jest.fn(),
setLineDash: jest.fn(),
strokeRect: jest.fn(),
};
const xValues = [10, 20, 30];
const u = {
ctx,
bbox: { left: 0, top: 0, width: 100, height: 100 },
data: [xValues, values],
series: [{}, { show: true, stroke: 'red', points: { size: 4 }, ...series }],
valToIdx: (value: number): number => xValues.indexOf(value),
valToPos: (value: number): number => value,
} as unknown as uPlot;
return { u, ctx };
}
describe('partialPointsDrawHook', () => {
it('draws a dot and a dotted ring on each partial point', () => {
const { u, ctx } = createPlot({ partialTimestamps: new Set([30]) });
partialPointsDrawHook(u);
expect(ctx.arc).toHaveBeenCalledTimes(2);
expect(ctx.arc).toHaveBeenCalledWith(30, 3, 2, 0, 2 * Math.PI);
expect(ctx.arc).toHaveBeenCalledWith(30, 3, 5, 0, 2 * Math.PI);
expect(ctx.setLineDash).toHaveBeenCalledWith([2, 2]);
expect(ctx.restore).toHaveBeenCalled();
});
it('skips hidden series, null values and timestamps not in the data', () => {
const hidden = createPlot({ show: false, partialTimestamps: new Set([30]) });
const missing = createPlot({ partialTimestamps: new Set([30]) }, [
1,
2,
null,
]);
const unknown = createPlot({ partialTimestamps: new Set([40]) });
[hidden, missing, unknown].forEach(({ u, ctx }) => {
partialPointsDrawHook(u);
expect(ctx.arc).not.toHaveBeenCalled();
});
});
it('draws a dotted outline around a partial bar', () => {
const { u, ctx } = createPlot({
partialTimestamps: new Set([30]),
paths: (plot, seriesIdx, idx) => {
reportBarRect(plot, seriesIdx, idx, 10, 20, 8, 30);
return null;
},
});
partialPointsDrawHook(u);
expect(ctx.strokeRect).toHaveBeenCalledWith(7, 17, 14, 36);
expect(ctx.arc).not.toHaveBeenCalled();
});
});

View File

@@ -1,89 +0,0 @@
import uPlot from 'uplot';
import { ExtendedSeries } from '../config/types';
import { BarRect, measureBar } from '../utils/barRect';
const DEFAULT_POINT_SIZE = 5;
const RING_GAP = 3;
const RING_WIDTH = 1.5;
const RING_DASH = [2, 2];
function drawRing(
u: uPlot,
series: ExtendedSeries,
idx: number,
value: number,
): void {
const { ctx } = u;
const { pxRatio } = uPlot;
const cx = u.valToPos(u.data[0][idx], 'x', true);
const cy = u.valToPos(value, series.scale ?? 'y', true);
const radius = ((series.points?.size ?? DEFAULT_POINT_SIZE) / 2) * pxRatio;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, 2 * Math.PI);
ctx.fill();
ctx.lineWidth = RING_WIDTH * pxRatio;
ctx.setLineDash(RING_DASH.map((segment) => segment * pxRatio));
ctx.beginPath();
ctx.arc(cx, cy, radius + RING_GAP * pxRatio, 0, 2 * Math.PI);
ctx.stroke();
}
function drawBarOutline(u: uPlot, rect: BarRect): void {
const gap = RING_GAP * uPlot.pxRatio;
u.ctx.lineWidth = RING_WIDTH * uPlot.pxRatio;
u.ctx.setLineDash(RING_DASH.map((segment) => segment * uPlot.pxRatio));
u.ctx.strokeRect(
rect.left - gap,
rect.top - gap,
rect.width + 2 * gap,
rect.height + 2 * gap,
);
}
/**
* Marks each series' partial buckets: a dotted outline around bars, a dot and dotted
* ring on line and point series.
*/
export function partialPointsDrawHook(u: uPlot): void {
const { ctx, bbox } = u;
const xValues = u.data[0];
ctx.save();
ctx.beginPath();
ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height);
ctx.clip();
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx += 1) {
const series = u.series[seriesIdx] as ExtendedSeries;
if (!series.show || !series.partialTimestamps) {
continue;
}
const color =
typeof series.stroke === 'function'
? series.stroke(u, seriesIdx)
: (series.stroke ?? '');
ctx.fillStyle = color;
ctx.strokeStyle = color;
ctx.globalAlpha = series.alpha ?? 1;
series.partialTimestamps.forEach((timestamp) => {
const idx = u.valToIdx(timestamp);
const value = u.data[seriesIdx][idx];
if (xValues[idx] !== timestamp || value == null) {
return;
}
ctx.setLineDash([]);
const bar = measureBar(u, seriesIdx, idx);
if (bar) {
drawBarOutline(u, bar);
} else {
drawRing(u, series, idx, value);
}
});
}
ctx.restore();
}

View File

@@ -1,30 +0,0 @@
import type uPlot from 'uplot';
import { measureBar, reportBarRect } from '../barRect';
function plotWithPaths(paths?: uPlot.Series['paths']): uPlot {
return { series: [{}, { paths }] } as unknown as uPlot;
}
describe('measureBar', () => {
it('returns the rect the bar paths report for that index', () => {
const paths = jest.fn((u: uPlot, seriesIdx: number, idx: number) => {
reportBarRect(u, seriesIdx, idx, 10, 20, 8, 30);
return null;
});
const u = plotWithPaths(paths);
expect(measureBar(u, 1, 4)).toStrictEqual({
left: 10,
top: 20,
width: 8,
height: 30,
});
expect(paths).toHaveBeenCalledWith(u, 1, 4, 4);
});
it('returns null for series whose paths report no bar', () => {
expect(measureBar(plotWithPaths(jest.fn(() => null)), 1, 0)).toBeNull();
expect(measureBar(plotWithPaths(), 1, 0)).toBeNull();
});
});

View File

@@ -1,45 +0,0 @@
import type uPlot from 'uplot';
export interface BarRect {
left: number;
top: number;
width: number;
height: number;
}
let onBarRect: ((rect: BarRect) => void) | null = null;
/** `each` callback for bar path builders; reports bar rects while {@link measureBar} runs. */
// eslint-disable-next-line max-params
export function reportBarRect(
_u: uPlot,
_seriesIdx: number,
_idx: number,
left: number,
top: number,
width: number,
height: number,
): void {
onBarRect?.({ left, top, width, height });
}
/**
* Canvas rect of one bar, from re-running the series' paths for that index.
* `null` for series not drawn as bars.
*/
export function measureBar(
u: uPlot,
seriesIdx: number,
idx: number,
): BarRect | null {
let rect: BarRect | null = null;
onBarRect = (measured): void => {
rect = measured;
};
try {
u.series[seriesIdx].paths?.(u, seriesIdx, idx, idx);
} finally {
onBarRect = null;
}
return rect;
}

View File

@@ -15,7 +15,6 @@ import {
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import {
getPartialTimestamps,
hasSingleVisiblePoint,
toClickPluginPayload,
} from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
@@ -156,7 +155,6 @@ function addSeries({
fillOpacity,
isDarkMode,
metric: s.labels,
partialTimestamps: getPartialTimestamps(s.values),
});
});
}

View File

@@ -1,34 +0,0 @@
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { UTC_TIMEZONE } from 'components/CustomTimePicker/timezoneUtils';
import type { ExtendedSeries } from 'lib/uPlotV2/config/types';
import { PanelMode } from 'lib/visualization/panels/types';
import { buildBarChartConfig } from '../buildConfig';
describe('buildBarChartConfig partial buckets', () => {
it('passes partial timestamps (epoch seconds) to the series', () => {
const config = buildBarChartConfig({
panelId: 'p1',
spec: {} as DashboardtypesBarChartPanelSpecDTO,
builderQueries: [],
series: [
{
queryName: 'A',
legend: 'A',
labels: {},
values: [
{ timestamp: 1000, value: 1 },
{ timestamp: 61000, value: 0.2, partial: true },
],
kind: 'series',
aggregation: { index: 0, alias: 'A' },
},
],
isDarkMode: false,
timezone: UTC_TIMEZONE,
panelMode: PanelMode.DASHBOARD_VIEW,
}).getConfig().series?.[1] as ExtendedSeries;
expect(config.partialTimestamps).toStrictEqual(new Set([61]));
});
});

View File

@@ -5,10 +5,7 @@ import {
} from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import {
getPartialTimestamps,
toClickPluginPayload,
} from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
import { toClickPluginPayload } from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
import getLabelName from 'lib/getLabelName';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
@@ -105,7 +102,6 @@ function addSeries({
isDarkMode,
stepInterval,
metric: s.labels,
partialTimestamps: getPartialTimestamps(s.values),
});
});
}

View File

@@ -1,6 +1,5 @@
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { UTC_TIMEZONE } from 'components/CustomTimePicker/timezoneUtils';
import type { ExtendedSeries } from 'lib/uPlotV2/config/types';
import { PanelMode } from 'lib/visualization/panels/types';
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
@@ -75,27 +74,3 @@ describe('buildTimeSeriesConfig spanGaps', () => {
).toStrictEqual([10]);
});
});
describe('buildTimeSeriesConfig partial buckets', () => {
it('passes partial timestamps (epoch seconds) to the series', () => {
const config = buildTimeSeriesConfig({
panelId: 'p1',
spec: {} as DashboardtypesTimeSeriesPanelSpecDTO,
builderQueries: [],
series: [
{
...series[0],
values: [
{ timestamp: 1000, value: 1 },
{ timestamp: 61000, value: 0.2, partial: true },
],
},
],
isDarkMode: false,
timezone: UTC_TIMEZONE,
panelMode: PanelMode.DASHBOARD_VIEW,
}).getConfig().series?.[1] as ExtendedSeries;
expect(config.partialTimestamps).toStrictEqual(new Set([61]));
});
});

View File

@@ -13,7 +13,6 @@ import { resolveSpanGaps } from 'pages/DashboardPage/DashboardContainer/Panels/u
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import {
getPartialTimestamps,
hasSingleVisiblePoint,
toClickPluginPayload,
} from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
@@ -150,7 +149,6 @@ function addSeries({
fillMode,
isDarkMode,
metric: s.labels,
partialTimestamps: getPartialTimestamps(s.values),
});
});
}

View File

@@ -1,6 +1,5 @@
import type { PanelSeries } from '../types';
import {
getPartialTimestamps,
hasSingleVisiblePoint,
prepareAlignedData,
toClickPluginPayload,
@@ -86,22 +85,6 @@ describe('hasSingleVisiblePoint', () => {
});
});
describe('getPartialTimestamps', () => {
it('returns partial timestamps in epoch seconds', () => {
expect(
getPartialTimestamps([
{ timestamp: 1_000_500, value: 1, partial: true },
{ timestamp: 2_000_000, value: 2 },
{ timestamp: 3_000_000, value: 3, partial: true },
]),
).toStrictEqual(new Set([1000, 3000]));
});
it('returns undefined when nothing is partial', () => {
expect(getPartialTimestamps([{ timestamp: 1000, value: 1 }])).toBeUndefined();
});
});
describe('toClickPluginPayload', () => {
it('produces the tuple-shaped legacy result the shared click plugin reads', () => {
const payload = toClickPluginPayload([

View File

@@ -41,19 +41,6 @@ export function prepareAlignedData(series: PanelSeries[]): uPlot.AlignedData {
return [timestamps, ...yValues] as uPlot.AlignedData;
}
/** Epoch-second x values of the series' partial buckets; `undefined` when none are partial. */
export function getPartialTimestamps(
values: PanelSeriesPoint[],
): Set<number> | undefined {
const timestamps = new Set<number>();
values.forEach((point) => {
if (point.partial) {
timestamps.add(Math.floor(point.timestamp / 1000));
}
});
return timestamps.size ? timestamps : undefined;
}
/**
* True when the series has at most one finite point — can't draw a line, so renderers degrade to
* points (V1 `hasSingleVisiblePoint` parity).

View File

@@ -1,29 +0,0 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { getSpanDisplayData } from '../utils';
describe('getSpanDisplayData', () => {
it('keeps only allow-listed fields', () => {
const span = {
span_id: 's1',
trace_id: 't1',
name: 'GET /api',
attributes: { 'http.method': 'GET' },
resource: { 'service.name': 'api' },
level: 1,
has_children: true,
sub_tree_node_count: 3,
'service.name': 'api',
time_unix: 1700000000,
formattedInput: '[]',
} as unknown as SpanV3;
expect(getSpanDisplayData(span)).toStrictEqual({
span_id: 's1',
trace_id: 't1',
name: 'GET /api',
attributes: { 'http.method': 'GET' },
resource: { 'service.name': 'api' },
});
});
});

View File

@@ -1,5 +1,3 @@
import type { SpantypesWaterfallSpanDTO } from 'api/generated/services/sigNoz.schemas';
import { pick } from 'lodash-es';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { COLOR_BY_OPTIONS } from './constants';
@@ -48,43 +46,28 @@ export function hasInfraMetadata(span: SpanV3 | undefined): boolean {
return INFRA_METADATA_KEYS.some((key) => getSpanAttribute(span, key));
}
// Top-level fields shown in the Span Details DataViewer. Everything else on the
// API response (e.g. waterfall rendering data) is hidden.
const VISIBLE_SPAN_FIELDS_IN_DETAILS_VIEW = [
'span_id',
'trace_id',
'parent_span_id',
'name',
'kind_string',
'duration_nano',
'has_error',
'status_code',
'status_code_string',
'status_message',
'flags',
'is_remote',
'trace_state',
'attributes',
'resource',
'events',
'references',
'http_method',
'http_url',
'http_host',
'db_name',
'db_operation',
'external_http_method',
'external_http_url',
'response_status_code',
// Keys must exist on the waterfall DTO.
] as const satisfies readonly (keyof SpantypesWaterfallSpanDTO)[];
// Top-level fields that exist on the API response only to support waterfall
// rendering. They have no value in the Span Details DataViewer. Drop the whole
// constant + helper once the backend stops emitting them.
const HIDDEN_SPAN_FIELDS_IN_DETAILS_VIEW: ReadonlySet<string> = new Set([
'sub_tree_node_count',
'has_children',
'level',
'service.name',
]);
/**
* Shallow-copies the allow-listed span fields, for display in the Span Details
* DataViewer.
* Shallow-copies the span with waterfall-only fields stripped, for display in
* the Span Details DataViewer.
*/
export function getSpanDisplayData(span: SpanV3): Record<string, unknown> {
return pick(span, VISIBLE_SPAN_FIELDS_IN_DETAILS_VIEW);
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(span)) {
if (!HIDDEN_SPAN_FIELDS_IN_DETAILS_VIEW.has(key)) {
result[key] = value;
}
}
return result;
}
/**

View File

@@ -21,8 +21,6 @@ export interface QueryData {
queryName: string;
legend?: string;
values: [number, string][];
/** Epoch-second x values (as in `values`) of buckets that don't cover a full step. */
partialTimestamps?: number[];
quantity?: number[];
unit?: string;
table?: {
@@ -47,7 +45,7 @@ export interface SeriesItem {
[key: string]: string;
};
labelsArray: { [key: string]: string }[];
values: { timestamp: number; value: string; partial?: boolean }[];
values: { timestamp: number; value: string }[];
metaData?: {
alias: string;
index: number;

View File

@@ -4,16 +4,14 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/gorilla/mux"
)
func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/infra_monitoring/hosts", handler.New(
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListHosts, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListHosts),
handler.OpenAPIDef{
ID: "ListHosts",
Tags: []string{"inframonitoring"},
@@ -26,18 +24,13 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/pods", handler.New(
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListPods, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListPods),
handler.OpenAPIDef{
ID: "ListPods",
Tags: []string{"inframonitoring"},
@@ -50,18 +43,13 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/kube_containers", handler.New(
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListContainers, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListContainers),
handler.OpenAPIDef{
ID: "ListContainers",
Tags: []string{"inframonitoring"},
@@ -74,18 +62,13 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/nodes", handler.New(
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListNodes, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListNodes),
handler.OpenAPIDef{
ID: "ListNodes",
Tags: []string{"inframonitoring"},
@@ -98,18 +81,13 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/namespaces", handler.New(
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListNamespaces, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListNamespaces),
handler.OpenAPIDef{
ID: "ListNamespaces",
Tags: []string{"inframonitoring"},
@@ -122,18 +100,13 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/clusters", handler.New(
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListClusters, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListClusters),
handler.OpenAPIDef{
ID: "ListClusters",
Tags: []string{"inframonitoring"},
@@ -146,18 +119,13 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/pvcs", handler.New(
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListVolumes, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListVolumes),
handler.OpenAPIDef{
ID: "ListVolumes",
Tags: []string{"inframonitoring"},
@@ -170,18 +138,13 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/deployments", handler.New(
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListDeployments, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListDeployments),
handler.OpenAPIDef{
ID: "ListDeployments",
Tags: []string{"inframonitoring"},
@@ -194,18 +157,13 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/statefulsets", handler.New(
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListStatefulSets, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListStatefulSets),
handler.OpenAPIDef{
ID: "ListStatefulSets",
Tags: []string{"inframonitoring"},
@@ -218,18 +176,13 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/jobs", handler.New(
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListJobs, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListJobs),
handler.OpenAPIDef{
ID: "ListJobs",
Tags: []string{"inframonitoring"},
@@ -242,18 +195,13 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/daemonsets", handler.New(
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListDaemonSets, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListDaemonSets),
handler.OpenAPIDef{
ID: "ListDaemonSets",
Tags: []string{"inframonitoring"},
@@ -266,18 +214,13 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/checks", handler.New(
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.GetChecks, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.GetChecks),
handler.OpenAPIDef{
ID: "GetChecks",
Tags: []string{"inframonitoring"},
@@ -289,13 +232,8 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWildcardResources(coretypes.ResourceTelemetryResourceMetrics),
}))).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}

View File

@@ -84,26 +84,5 @@ func (provider *provider) addPromoteRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/promoted_paths/materialized", handler.New(provider.authzMiddleware.CheckResources(provider.promoteHandler.IndexMaterializedPaths, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName), handler.OpenAPIDef{
ID: "IndexMaterializedPaths",
Tags: []string{"promote"},
Summary: "Index materialized paths",
Description: "This endpoint indexes the JSON sub-columns of materialized paths whose columns are indexed and returns the indexed and skipped paths.",
Request: new(promotetypes.PostableIndexMaterializedPaths),
RequestContentType: "application/json",
Response: new(promotetypes.IndexMaterializedPathsResult),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceLogsField.Scope(coretypes.VerbUpdate), coretypes.ResourceMetaResourceTracesField.Scope(coretypes.VerbUpdate)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
Resources: promotetypes.IndexMaterializedPathsResources,
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -4,12 +4,13 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/gorilla/mux"
)
func (provider *provider) addStatsReporterRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/stats", handler.New(
provider.authzMiddleware.OpenAccess(provider.statsHandler.Get),
provider.authzMiddleware.ViewAccess(provider.statsHandler.Get),
handler.OpenAPIDef{
ID: "GetStats",
Tags: []string{"stats"},
@@ -22,7 +23,7 @@ func (provider *provider) addStatsReporterRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes(nil),
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err

View File

@@ -72,23 +72,3 @@ func (h *handler) ListPromotedPaths(w http.ResponseWriter, r *http.Request) {
render.Success(w, http.StatusOK, paths)
}
func (h *handler) IndexMaterializedPaths(w http.ResponseWriter, r *http.Request) {
var req promotetypes.PostableIndexMaterializedPaths
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
render.Error(w, err)
return
}
if err := req.Validate(); err != nil {
render.Error(w, err)
return
}
paths, err := h.module.IndexMaterializedPaths(r.Context(), req)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, paths)
}

View File

@@ -2,12 +2,10 @@ package implpromote
import (
"context"
"log/slog"
"strings"
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/promote"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
@@ -24,15 +22,10 @@ var (
type module struct {
metadataStore telemetrytypes.MetadataStore
telemetryStore telemetrystore.TelemetryStore
settings factory.ScopedProviderSettings
}
func NewModule(metadataStore telemetrytypes.MetadataStore, telemetrystore telemetrystore.TelemetryStore, providerSettings factory.ProviderSettings) promote.Module {
return &module{
metadataStore: metadataStore,
telemetryStore: telemetrystore,
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/promote/implpromote"),
}
func NewModule(metadataStore telemetrytypes.MetadataStore, telemetrystore telemetrystore.TelemetryStore) promote.Module {
return &module{metadataStore: metadataStore, telemetryStore: telemetrystore}
}
func (m *module) ListPromotedPaths(ctx context.Context, filters promotetypes.ListPromotedPathsFilters) ([]promotetypes.PromotePath, error) {
@@ -108,71 +101,6 @@ func (m *module) listPromotedPaths(ctx context.Context, target promotetypes.Targ
return response, nil
}
func (m *module) IndexMaterializedPaths(ctx context.Context, req promotetypes.PostableIndexMaterializedPaths) (*promotetypes.IndexMaterializedPathsResult, error) {
target, err := req.Target()
if err != nil {
return nil, err
}
keys, err := m.metadataStore.GetMaterializedKeys(ctx, target.Entry.Signal)
if err != nil {
return nil, err
}
paths := []*promotetypes.PromotePath{}
names := []string{}
for _, key := range keys {
if key.FieldContext != target.Entry.FieldContext || len(key.Indexes) == 0 {
continue
}
path, err := promotetypes.NewMaterializedPromotePath(target, key)
if err != nil {
m.settings.Logger().WarnContext(ctx, "skipping materialized path that cannot be indexed", slog.String("path", key.Name), errors.Attr(err))
continue
}
paths = append(paths, path)
names = append(names, path.Path)
}
result := &promotetypes.IndexMaterializedPathsResult{Indexed: []*promotetypes.PromotePath{}, Skipped: []*promotetypes.PromotePath{}}
if len(paths) > 0 {
existing, err := m.metadataStore.ListJSONIndexes(ctx, target.JSONIndexLookup(), names...)
if err != nil {
return nil, err
}
existingByPath := map[string][]promotetypes.WrappedIndex{}
for _, index := range existing {
existingByPath[index.Name] = append(existingByPath[index.Name], promotetypes.WrappedIndex{FieldDataType: index.FieldDataType, Type: index.IndexType, Granularity: index.Granularity})
}
for _, path := range paths {
if indexes, ok := existingByPath[path.Path]; ok {
path.Indexes = indexes
result.Skipped = append(result.Skipped, path)
continue
}
result.Indexed = append(result.Indexed, path)
}
}
if !req.DryRun && len(result.Indexed) > 0 {
if err := m.promotePaths(ctx, target, result.Indexed...); err != nil {
return nil, err
}
}
indexed := make([]string, 0, len(result.Indexed))
for _, path := range result.Indexed {
indexed = append(indexed, path.Path)
}
skipped := make([]string, 0, len(result.Skipped))
for _, path := range result.Skipped {
skipped = append(skipped, path.Path)
}
m.settings.Logger().InfoContext(ctx, "indexed materialized paths", slog.String("signal", req.Signal), slog.Bool("dry_run", req.DryRun), slog.Int("indexed_count", len(indexed)), slog.Any("indexed", indexed), slog.Int("skipped_count", len(skipped)), slog.Any("skipped", skipped))
return result, nil
}
func (m *module) PromotePaths(ctx context.Context, paths ...*promotetypes.PromotePath) error {
byTarget := map[promotetypes.Target][]*promotetypes.PromotePath{}
targets := []promotetypes.Target{}

View File

@@ -9,7 +9,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/types/promotetypes"
@@ -72,7 +71,7 @@ func TestPromotePaths(t *testing.T) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
store := telemetrytypestest.NewMockMetadataStore()
m := NewModule(store, nil, factorytest.NewSettings())
m := NewModule(store, nil)
err := m.PromotePaths(ctx, testCase.paths...)
if testCase.wantErr {
@@ -195,7 +194,7 @@ func TestPromotePathsCreatesIndexes(t *testing.T) {
ts := telemetrystoretest.New(telemetrystore.Config{}, sqlmock.QueryMatcherRegexp)
store := telemetrytypestest.NewMockMetadataStore()
store.PromotedPathsMap[telemetrytypes.SignalLogs] = testCase.promoted
m := NewModule(store, ts, factorytest.NewSettings())
m := NewModule(store, ts)
ts.Mock().ExpectExec("ADD INDEX (.+)" + regexp.QuoteMeta(testCase.wantDDLColumn)).WillReturnError(nil)
require.NoError(t, m.PromotePaths(ctx, testCase.path))
@@ -391,7 +390,7 @@ func TestListPromotedPaths(t *testing.T) {
store := telemetrytypestest.NewMockMetadataStore()
store.PromotedPathsMap = testCase.promoted
store.LogsJSONIndexes = testCase.indexes
m := NewModule(store, nil, factorytest.NewSettings())
m := NewModule(store, nil)
paths, err := m.ListPromotedPaths(ctx, testCase.filters)
require.NoError(t, err)
@@ -409,104 +408,3 @@ func TestListPromotedPaths(t *testing.T) {
})
}
}
func TestIndexMaterializedPaths(t *testing.T) {
ctx := context.Background()
bloomIndex := []telemetrytypes.TelemetryFieldKeySkipIndex{{IndexType: "bloom_filter", Granularity: 4}}
materializedKeys := []*telemetrytypes.TelemetryFieldKey{
{Name: "user.id", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString, Indexes: bloomIndex},
{Name: "tenant.id", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "retry.count", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeNumber, Indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{{IndexType: "minmax", Granularity: 1}}},
{Name: "k8s.pod.name", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString, Indexes: bloomIndex},
}
userIDDDL := "`attributes.user.id_String_bloom_filter` attributes.`user.id`::String TYPE bloom_filter GRANULARITY 4"
retryCountDDL := "`attributes.retry.count_Float64_minmax` attributes.`retry.count`::Float64 TYPE minmax GRANULARITY 1"
testCases := []struct {
name string
req promotetypes.PostableIndexMaterializedPaths
keys []*telemetrytypes.TelemetryFieldKey
existing []telemetrytypes.TelemetryFieldKeySkipIndex
wantIndexed []string
wantSkipped []string
wantDDL []string
wantErr bool
}{
{
name: "DryRun_NoDDL",
req: promotetypes.PostableIndexMaterializedPaths{Signal: "traces", DryRun: true},
keys: materializedKeys,
wantIndexed: []string{"user.id", "retry.count"},
wantSkipped: []string{},
},
{
name: "Run_IndexesBaseColumn",
req: promotetypes.PostableIndexMaterializedPaths{Signal: "traces"},
keys: materializedKeys,
wantIndexed: []string{"user.id", "retry.count"},
wantSkipped: []string{},
wantDDL: []string{userIDDDL, retryCountDDL},
},
{
name: "AnyIndexBuilt_Skipped",
req: promotetypes.PostableIndexMaterializedPaths{Signal: "traces"},
keys: materializedKeys,
existing: []telemetrytypes.TelemetryFieldKeySkipIndex{{Name: "user.id", FieldContext: telemetrytypes.FieldContextAttribute, IndexType: "ngrambf_v1(4, 1024, 2, 0)"}},
wantIndexed: []string{"retry.count"},
wantSkipped: []string{"user.id"},
wantDDL: []string{retryCountDDL},
},
{
name: "UnsupportedIndexType_LoggedAndLeftOut",
req: promotetypes.PostableIndexMaterializedPaths{Signal: "traces"},
keys: append([]*telemetrytypes.TelemetryFieldKey{
{Name: "user.email", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString, Indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{{IndexType: "text(tokenizer = 'splitByNonAlpha')", Granularity: 1}}},
}, materializedKeys...),
wantIndexed: []string{"user.id", "retry.count"},
wantSkipped: []string{},
wantDDL: []string{userIDDDL, retryCountDDL},
},
{
name: "LogsSignal_NoAttributeTarget_Rejected",
req: promotetypes.PostableIndexMaterializedPaths{Signal: "logs"},
wantErr: true,
},
{
name: "MissingSignal_Rejected",
wantErr: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
ts := telemetrystoretest.New(telemetrystore.Config{}, sqlmock.QueryMatcherRegexp)
store := telemetrytypestest.NewMockMetadataStore()
store.MaterializedKeys = testCase.keys
store.LogsJSONIndexes = testCase.existing
m := NewModule(store, ts, factorytest.NewSettings())
for _, ddl := range testCase.wantDDL {
ts.Mock().ExpectExec("ADD INDEX (.+)" + regexp.QuoteMeta(ddl)).WillReturnError(nil)
}
result, err := m.IndexMaterializedPaths(ctx, testCase.req)
if testCase.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
indexed := []string{}
for _, path := range result.Indexed {
assert.False(t, path.Promote)
indexed = append(indexed, path.Path)
}
skipped := []string{}
for _, path := range result.Skipped {
skipped = append(skipped, path.Path)
}
assert.Equal(t, testCase.wantIndexed, indexed)
assert.Equal(t, testCase.wantSkipped, skipped)
assert.NoError(t, ts.Mock().ExpectationsWereMet())
})
}
}

View File

@@ -10,12 +10,9 @@ import (
type Module interface {
ListPromotedPaths(ctx context.Context, filters promotetypes.ListPromotedPathsFilters) ([]promotetypes.PromotePath, error)
PromotePaths(ctx context.Context, paths ...*promotetypes.PromotePath) error
IndexMaterializedPaths(ctx context.Context, req promotetypes.PostableIndexMaterializedPaths) (*promotetypes.IndexMaterializedPathsResult, error)
}
type Handler interface {
PromotePaths(w http.ResponseWriter, r *http.Request)
ListPromotedPaths(w http.ResponseWriter, r *http.Request)
IndexMaterializedPaths(w http.ResponseWriter, r *http.Request)
}

View File

@@ -62,6 +62,8 @@ const (
primaryNamespace = "clickhouse"
archiveNamespace = "clickhouse-archive"
signozTraceDBName = "signoz_traces"
signozHistoryDBName = "signoz_analytics"
ruleStateHistoryTableName = "distributed_rule_state_history_v0"
signozDurationMVTable = "distributed_durationSort"
signozUsageExplorerTable = "distributed_usage_explorer"
signozSpansTable = "distributed_signoz_spans"
@@ -4482,6 +4484,483 @@ func (r *ClickHouseReader) GetSpanAttributeKeysByNames(ctx context.Context, name
return response, nil
}
func (r *ClickHouseReader) AddRuleStateHistory(ctx context.Context, ruleStateHistory []model.RuleStateHistory) error {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "AddRuleStateHistory",
})
var statement driver.Batch
var err error
defer func() {
if statement != nil {
statement.Abort()
}
}()
statement, err = r.db.PrepareBatch(ctx, fmt.Sprintf("INSERT INTO %s.%s (rule_id, rule_name, overall_state, overall_state_changed, state, state_changed, unix_milli, labels, fingerprint, value) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
signozHistoryDBName, ruleStateHistoryTableName))
if err != nil {
return err
}
for _, history := range ruleStateHistory {
err = statement.Append(history.RuleID, history.RuleName, history.OverallState, history.OverallStateChanged, history.State, history.StateChanged, history.UnixMilli, history.Labels, history.Fingerprint, history.Value)
if err != nil {
return err
}
}
err = statement.Send()
if err != nil {
return err
}
return nil
}
func (r *ClickHouseReader) GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]model.RuleStateHistory, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "GetLastSavedRuleStateHistory",
})
query := fmt.Sprintf("SELECT * FROM %s.%s WHERE rule_id = '%s' AND state_changed = true ORDER BY unix_milli DESC LIMIT 1 BY fingerprint",
signozHistoryDBName, ruleStateHistoryTableName, ruleID)
history := []model.RuleStateHistory{}
err := r.db.Select(ctx, &history, query)
if err != nil {
return nil, err
}
return history, nil
}
func (r *ClickHouseReader) ReadRuleStateHistoryByRuleID(
ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (*model.RuleStateTimeline, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "ReadRuleStateHistoryByRuleID",
})
var conditions []string
conditions = append(conditions, fmt.Sprintf("rule_id = '%s'", ruleID))
conditions = append(conditions, fmt.Sprintf("unix_milli >= %d AND unix_milli < %d", params.Start, params.End))
if params.State != "" {
conditions = append(conditions, fmt.Sprintf("state = '%s'", params.State))
}
if params.Filters != nil && len(params.Filters.Items) != 0 {
for _, item := range params.Filters.Items {
toFormat := item.Value
op := v3.FilterOperator(strings.ToLower(strings.TrimSpace(string(item.Operator))))
if op == v3.FilterOperatorContains || op == v3.FilterOperatorNotContains {
toFormat = fmt.Sprintf("%%%s%%", toFormat)
}
fmtVal := utils.ClickHouseFormattedValue(toFormat)
switch op {
case v3.FilterOperatorEqual:
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, '%s') = %s", item.Key.Key, fmtVal))
case v3.FilterOperatorNotEqual:
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, '%s') != %s", item.Key.Key, fmtVal))
case v3.FilterOperatorIn:
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, '%s') IN %s", item.Key.Key, fmtVal))
case v3.FilterOperatorNotIn:
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, '%s') NOT IN %s", item.Key.Key, fmtVal))
case v3.FilterOperatorLike:
conditions = append(conditions, fmt.Sprintf("like(JSONExtractString(labels, '%s'), %s)", item.Key.Key, fmtVal))
case v3.FilterOperatorNotLike:
conditions = append(conditions, fmt.Sprintf("notLike(JSONExtractString(labels, '%s'), %s)", item.Key.Key, fmtVal))
case v3.FilterOperatorRegex:
conditions = append(conditions, fmt.Sprintf("match(JSONExtractString(labels, '%s'), %s)", item.Key.Key, fmtVal))
case v3.FilterOperatorNotRegex:
conditions = append(conditions, fmt.Sprintf("not match(JSONExtractString(labels, '%s'), %s)", item.Key.Key, fmtVal))
case v3.FilterOperatorGreaterThan:
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, '%s') > %s", item.Key.Key, fmtVal))
case v3.FilterOperatorGreaterThanOrEq:
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, '%s') >= %s", item.Key.Key, fmtVal))
case v3.FilterOperatorLessThan:
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, '%s') < %s", item.Key.Key, fmtVal))
case v3.FilterOperatorLessThanOrEq:
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, '%s') <= %s", item.Key.Key, fmtVal))
case v3.FilterOperatorContains:
conditions = append(conditions, fmt.Sprintf("like(JSONExtractString(labels, '%s'), %s)", item.Key.Key, fmtVal))
case v3.FilterOperatorNotContains:
conditions = append(conditions, fmt.Sprintf("notLike(JSONExtractString(labels, '%s'), %s)", item.Key.Key, fmtVal))
case v3.FilterOperatorExists:
conditions = append(conditions, fmt.Sprintf("has(JSONExtractKeys(labels), '%s')", item.Key.Key))
case v3.FilterOperatorNotExists:
conditions = append(conditions, fmt.Sprintf("not has(JSONExtractKeys(labels), '%s')", item.Key.Key))
default:
return nil, fmt.Errorf("unsupported filter operator")
}
}
}
whereClause := strings.Join(conditions, " AND ")
query := fmt.Sprintf("SELECT * FROM %s.%s WHERE %s ORDER BY unix_milli %s LIMIT %d OFFSET %d",
signozHistoryDBName, ruleStateHistoryTableName, whereClause, params.Order, params.Limit, params.Offset)
history := []model.RuleStateHistory{}
r.logger.Debug("rule state history query", "query", query)
err := r.db.Select(ctx, &history, query)
if err != nil {
r.logger.Error("Error while reading rule state history", errorsV2.Attr(err))
return nil, err
}
var total uint64
r.logger.Debug("rule state history total query", "query", fmt.Sprintf("SELECT count(*) FROM %s.%s WHERE %s",
signozHistoryDBName, ruleStateHistoryTableName, whereClause))
err = r.db.QueryRow(ctx, fmt.Sprintf("SELECT count(*) FROM %s.%s WHERE %s",
signozHistoryDBName, ruleStateHistoryTableName, whereClause)).Scan(&total)
if err != nil {
return nil, err
}
labelsQuery := fmt.Sprintf("SELECT DISTINCT labels FROM %s.%s WHERE rule_id = $1",
signozHistoryDBName, ruleStateHistoryTableName)
rows, err := r.db.Query(ctx, labelsQuery, ruleID)
if err != nil {
return nil, err
}
defer rows.Close()
labelsMap := make(map[string][]string)
for rows.Next() {
var rawLabel string
err = rows.Scan(&rawLabel)
if err != nil {
return nil, err
}
label := map[string]string{}
err = json.Unmarshal([]byte(rawLabel), &label)
if err != nil {
return nil, err
}
for k, v := range label {
labelsMap[k] = append(labelsMap[k], v)
}
}
timeline := &model.RuleStateTimeline{
Items: history,
Total: total,
Labels: labelsMap,
}
return timeline, nil
}
func (r *ClickHouseReader) ReadRuleStateHistoryTopContributorsByRuleID(
ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) ([]model.RuleStateHistoryContributor, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "ReadRuleStateHistoryTopContributorsByRuleID",
})
query := fmt.Sprintf(`SELECT
fingerprint,
any(labels) as labels,
count(*) as count
FROM %s.%s
WHERE rule_id = '%s' AND (state_changed = true) AND (state = '%s') AND unix_milli >= %d AND unix_milli <= %d
GROUP BY fingerprint
HAVING labels != '{}'
ORDER BY count DESC`,
signozHistoryDBName, ruleStateHistoryTableName, ruleID, model.StateFiring.String(), params.Start, params.End)
r.logger.Debug("rule state history top contributors query", "query", query)
contributors := []model.RuleStateHistoryContributor{}
err := r.db.Select(ctx, &contributors, query)
if err != nil {
r.logger.Error("Error while reading rule state history", errorsV2.Attr(err))
return nil, err
}
return contributors, nil
}
func (r *ClickHouseReader) GetOverallStateTransitions(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) ([]model.ReleStateItem, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "GetOverallStateTransitions",
})
tmpl := `WITH firing_events AS (
SELECT
rule_id,
state,
unix_milli AS firing_time
FROM %s.%s
WHERE overall_state = '` + model.StateFiring.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
),
resolution_events AS (
SELECT
rule_id,
state,
unix_milli AS resolution_time
FROM %s.%s
WHERE overall_state = '` + model.StateInactive.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
),
matched_events AS (
SELECT
f.rule_id,
f.state,
f.firing_time,
MIN(r.resolution_time) AS resolution_time
FROM firing_events f
LEFT JOIN resolution_events r
ON f.rule_id = r.rule_id
WHERE r.resolution_time > f.firing_time
GROUP BY f.rule_id, f.state, f.firing_time
)
SELECT *
FROM matched_events
ORDER BY firing_time ASC;`
query := fmt.Sprintf(tmpl,
signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End,
signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End)
r.logger.Debug("overall state transitions query", "query", query)
transitions := []model.RuleStateTransition{}
err := r.db.Select(ctx, &transitions, query)
if err != nil {
return nil, err
}
stateItems := []model.ReleStateItem{}
for idx, item := range transitions {
start := item.FiringTime
end := item.ResolutionTime
stateItems = append(stateItems, model.ReleStateItem{
State: item.State,
Start: start,
End: end,
})
if idx < len(transitions)-1 {
nextStart := transitions[idx+1].FiringTime
if nextStart > end {
stateItems = append(stateItems, model.ReleStateItem{
State: model.StateInactive,
Start: end,
End: nextStart,
})
}
}
}
// fetch the most recent overall_state from the table
var state model.AlertState
stateQuery := fmt.Sprintf("SELECT state FROM %s.%s WHERE rule_id = '%s' AND unix_milli <= %d ORDER BY unix_milli DESC LIMIT 1",
signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.End)
if err := r.db.QueryRow(ctx, stateQuery).Scan(&state); err != nil {
if err != sql.ErrNoRows {
return nil, err
}
state = model.StateInactive
}
if len(transitions) == 0 {
// no transitions found, it is either firing or inactive for whole time range
stateItems = append(stateItems, model.ReleStateItem{
State: state,
Start: params.Start,
End: params.End,
})
} else {
// there were some transitions, we need to add the last state at the end
if state == model.StateInactive {
stateItems = append(stateItems, model.ReleStateItem{
State: model.StateInactive,
Start: transitions[len(transitions)-1].ResolutionTime,
End: params.End,
})
} else {
// fetch the most recent firing event from the table in the given time range
var firingTime int64
firingQuery := fmt.Sprintf(`
SELECT
unix_milli
FROM %s.%s
WHERE rule_id = '%s' AND overall_state_changed = true AND overall_state = '%s' AND unix_milli <= %d
ORDER BY unix_milli DESC LIMIT 1`, signozHistoryDBName, ruleStateHistoryTableName, ruleID, model.StateFiring.String(), params.End)
if err := r.db.QueryRow(ctx, firingQuery).Scan(&firingTime); err != nil {
return nil, err
}
stateItems = append(stateItems, model.ReleStateItem{
State: model.StateInactive,
Start: transitions[len(transitions)-1].ResolutionTime,
End: firingTime,
})
stateItems = append(stateItems, model.ReleStateItem{
State: model.StateFiring,
Start: firingTime,
End: params.End,
})
}
}
return stateItems, nil
}
func (r *ClickHouseReader) GetAvgResolutionTime(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (float64, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "GetAvgResolutionTime",
})
tmpl := `
WITH firing_events AS (
SELECT
rule_id,
state,
unix_milli AS firing_time
FROM %s.%s
WHERE overall_state = '` + model.StateFiring.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
),
resolution_events AS (
SELECT
rule_id,
state,
unix_milli AS resolution_time
FROM %s.%s
WHERE overall_state = '` + model.StateInactive.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
),
matched_events AS (
SELECT
f.rule_id,
f.state,
f.firing_time,
MIN(r.resolution_time) AS resolution_time
FROM firing_events f
LEFT JOIN resolution_events r
ON f.rule_id = r.rule_id
WHERE r.resolution_time > f.firing_time
GROUP BY f.rule_id, f.state, f.firing_time
)
SELECT AVG(resolution_time - firing_time) / 1000 AS avg_resolution_time
FROM matched_events;
`
query := fmt.Sprintf(tmpl,
signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End,
signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End)
r.logger.Debug("avg resolution time query", "query", query)
var avgResolutionTime float64
err := r.db.QueryRow(ctx, query).Scan(&avgResolutionTime)
if err != nil {
return 0, err
}
return avgResolutionTime, nil
}
func (r *ClickHouseReader) GetAvgResolutionTimeByInterval(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (*v3.Series, error) {
step := common.MinAllowedStepInterval(params.Start, params.End)
tmpl := `
WITH firing_events AS (
SELECT
rule_id,
state,
unix_milli AS firing_time
FROM %s.%s
WHERE overall_state = '` + model.StateFiring.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
),
resolution_events AS (
SELECT
rule_id,
state,
unix_milli AS resolution_time
FROM %s.%s
WHERE overall_state = '` + model.StateInactive.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
),
matched_events AS (
SELECT
f.rule_id,
f.state,
f.firing_time,
MIN(r.resolution_time) AS resolution_time
FROM firing_events f
LEFT JOIN resolution_events r
ON f.rule_id = r.rule_id
WHERE r.resolution_time > f.firing_time
GROUP BY f.rule_id, f.state, f.firing_time
)
SELECT toStartOfInterval(toDateTime(firing_time / 1000), INTERVAL %d SECOND) AS ts, AVG(resolution_time - firing_time) / 1000 AS avg_resolution_time
FROM matched_events
GROUP BY ts
ORDER BY ts ASC;`
query := fmt.Sprintf(tmpl,
signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End,
signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End, step)
r.logger.Debug("avg resolution time by interval query", "query", query)
result, err := r.GetTimeSeriesResultV3(ctx, query)
if err != nil || len(result) == 0 {
return nil, err
}
return result[0], nil
}
func (r *ClickHouseReader) GetTotalTriggers(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (uint64, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "GetTotalTriggers",
})
query := fmt.Sprintf("SELECT count(*) FROM %s.%s WHERE rule_id = '%s' AND (state_changed = true) AND (state = '%s') AND unix_milli >= %d AND unix_milli <= %d",
signozHistoryDBName, ruleStateHistoryTableName, ruleID, model.StateFiring.String(), params.Start, params.End)
var totalTriggers uint64
err := r.db.QueryRow(ctx, query).Scan(&totalTriggers)
if err != nil {
return 0, err
}
return totalTriggers, nil
}
func (r *ClickHouseReader) GetTriggersByInterval(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (*v3.Series, error) {
step := common.MinAllowedStepInterval(params.Start, params.End)
query := fmt.Sprintf("SELECT count(*), toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), INTERVAL %d SECOND) as ts FROM %s.%s WHERE rule_id = '%s' AND (state_changed = true) AND (state = '%s') AND unix_milli >= %d AND unix_milli <= %d GROUP BY ts ORDER BY ts ASC",
step, signozHistoryDBName, ruleStateHistoryTableName, ruleID, model.StateFiring.String(), params.Start, params.End)
result, err := r.GetTimeSeriesResultV3(ctx, query)
if err != nil || len(result) == 0 {
return nil, err
}
return result[0], nil
}
func (r *ClickHouseReader) GetMinAndMaxTimestampForTraceID(ctx context.Context, traceID []string) (int64, int64, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),

View File

@@ -15,6 +15,7 @@ import (
"github.com/SigNoz/signoz/pkg/queryparser"
"log/slog"
"math"
"net/http"
"regexp"
"slices"
@@ -38,6 +39,7 @@ import (
jsoniter "github.com/json-iterator/go"
_ "modernc.org/sqlite"
"github.com/SigNoz/signoz/pkg/contextlinks"
traceFunnelsModule "github.com/SigNoz/signoz/pkg/modules/tracefunnel"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
queues2 "github.com/SigNoz/signoz/pkg/query-service/app/integrations/messagingQueues/queues"
@@ -62,6 +64,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/opamptypes"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunneltypes"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations/messagingQueues/kafka"
@@ -367,6 +370,10 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.deleteRule)).Methods(http.MethodDelete)
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.patchRule)).Methods(http.MethodPatch)
router.HandleFunc("/api/v1/testRule", am.EditAccess(aH.testRule)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/rules/{id}/history/stats", am.ViewAccess(aH.getRuleStats)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/rules/{id}/history/timeline", am.ViewAccess(aH.getRuleStateHistory)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/rules/{id}/history/top_contributors", am.ViewAccess(aH.getRuleStateHistoryTopContributors)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/rules/{id}/history/overall_status", am.ViewAccess(aH.getOverallStateTransitions)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/variables/query", am.ViewAccess(aH.queryDashboardVarsV2)).Methods(http.MethodPost)
@@ -656,6 +663,217 @@ func (aH *APIHandler) testRule(w http.ResponseWriter, r *http.Request) {
aH.Respond(w, response)
}
func (aH *APIHandler) getRuleStats(w http.ResponseWriter, r *http.Request) {
ruleID := mux.Vars(r)["id"]
params := model.QueryRuleStateHistory{}
err := json.NewDecoder(r.Body).Decode(&params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
totalCurrentTriggers, err := aH.reader.GetTotalTriggers(r.Context(), ruleID, &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
currentTriggersSeries, err := aH.reader.GetTriggersByInterval(r.Context(), ruleID, &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
currentAvgResolutionTime, err := aH.reader.GetAvgResolutionTime(r.Context(), ruleID, &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
currentAvgResolutionTimeSeries, err := aH.reader.GetAvgResolutionTimeByInterval(r.Context(), ruleID, &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
if params.End-params.Start >= 86400000 {
days := int64(math.Ceil(float64(params.End-params.Start) / 86400000))
params.Start -= days * 86400000
params.End -= days * 86400000
} else {
params.Start -= 86400000
params.End -= 86400000
}
totalPastTriggers, err := aH.reader.GetTotalTriggers(r.Context(), ruleID, &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
pastTriggersSeries, err := aH.reader.GetTriggersByInterval(r.Context(), ruleID, &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
pastAvgResolutionTime, err := aH.reader.GetAvgResolutionTime(r.Context(), ruleID, &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
pastAvgResolutionTimeSeries, err := aH.reader.GetAvgResolutionTimeByInterval(r.Context(), ruleID, &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
if math.IsNaN(currentAvgResolutionTime) || math.IsInf(currentAvgResolutionTime, 0) {
currentAvgResolutionTime = 0
}
if math.IsNaN(pastAvgResolutionTime) || math.IsInf(pastAvgResolutionTime, 0) {
pastAvgResolutionTime = 0
}
stats := model.Stats{
TotalCurrentTriggers: totalCurrentTriggers,
TotalPastTriggers: totalPastTriggers,
CurrentTriggersSeries: currentTriggersSeries,
PastTriggersSeries: pastTriggersSeries,
CurrentAvgResolutionTime: strconv.FormatFloat(currentAvgResolutionTime, 'f', -1, 64),
PastAvgResolutionTime: strconv.FormatFloat(pastAvgResolutionTime, 'f', -1, 64),
CurrentAvgResolutionTimeSeries: currentAvgResolutionTimeSeries,
PastAvgResolutionTimeSeries: pastAvgResolutionTimeSeries,
}
aH.Respond(w, stats)
}
func (aH *APIHandler) getOverallStateTransitions(w http.ResponseWriter, r *http.Request) {
ruleID := mux.Vars(r)["id"]
params := model.QueryRuleStateHistory{}
err := json.NewDecoder(r.Body).Decode(&params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
stateItems, err := aH.reader.GetOverallStateTransitions(r.Context(), ruleID, &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
aH.Respond(w, stateItems)
}
func (aH *APIHandler) getRuleStateHistory(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
params := model.QueryRuleStateHistory{}
err = json.NewDecoder(r.Body).Decode(&params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
if err := params.Validate(); err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
res, err := aH.reader.ReadRuleStateHistoryByRuleID(r.Context(), id.StringValue(), &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
rule, err := aH.ruleManager.GetRule(r.Context(), id)
if err == nil {
for idx := range res.Items {
lbls := make(map[string]string)
err := json.Unmarshal([]byte(res.Items[idx].Labels), &lbls)
if err != nil {
continue
}
end := time.Unix(res.Items[idx].UnixMilli/1000, 0)
// why are we subtracting 3 minutes?
// the query range is calculated based on the rule's evalWindow and evalDelay
// alerts have 2 minutes delay built in, so we need to subtract that from the start time
// to get the correct query range
start := end.Add(-rule.EvalWindow.Duration() - 3*time.Minute)
if rule.AlertType == ruletypes.AlertTypeLogs {
// TODO(srikanthccv): re-visit this and support multiple queries
var q qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
for _, query := range rule.RuleCondition.CompositeQuery.Queries {
if query.Type == qbtypes.QueryTypeBuilder {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
q = spec
}
}
}
filterExpr := ""
if q.Filter != nil && q.Filter.Expression != "" {
filterExpr = q.Filter.Expression
}
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedLogsLink = contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode()
} else if rule.AlertType == ruletypes.AlertTypeTraces || rule.AlertType == ruletypes.AlertTypeAITraces {
// TODO(srikanthccv): re-visit this and support multiple queries
var q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]
for _, query := range rule.RuleCondition.CompositeQuery.Queries {
if query.Type == qbtypes.QueryTypeBuilder || query.Type == qbtypes.QueryTypeBuilderAI {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
q = spec
}
}
}
filterExpr := ""
if q.Filter != nil && q.Filter.Expression != "" {
filterExpr = q.Filter.Expression
}
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedTracesLink = contextlinks.PrepareParamsForTracesV5(start, end, whereClause, rule.AlertType.BuilderQueryType()).Encode()
}
}
}
aH.Respond(w, res)
}
func (aH *APIHandler) getRuleStateHistoryTopContributors(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
params := model.QueryRuleStateHistory{}
err = json.NewDecoder(r.Body).Decode(&params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
res, err := aH.reader.ReadRuleStateHistoryTopContributorsByRuleID(r.Context(), id.StringValue(), &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
aH.Respond(w, res)
}
func prepareQuery(r *http.Request) (string, error) {
var postData *model.DashboardVars

View File

@@ -79,6 +79,16 @@ type Reader interface {
GetMetricMetadata(context.Context, valuer.UUID, string, string) (*v3.MetricMetadataResponse, error)
AddRuleStateHistory(ctx context.Context, ruleStateHistory []model.RuleStateHistory) error
GetOverallStateTransitions(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) ([]model.ReleStateItem, error)
ReadRuleStateHistoryByRuleID(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (*model.RuleStateTimeline, error)
GetTotalTriggers(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (uint64, error)
GetTriggersByInterval(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (*v3.Series, error)
GetAvgResolutionTime(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (float64, error)
GetAvgResolutionTimeByInterval(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (*v3.Series, error)
ReadRuleStateHistoryTopContributorsByRuleID(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) ([]model.RuleStateHistoryContributor, error)
GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]model.RuleStateHistory, error)
GetMinAndMaxTimestampForTraceID(ctx context.Context, traceID []string) (int64, int64, error)
//trace

View File

@@ -0,0 +1,202 @@
package model
import (
"database/sql/driver"
"encoding/json"
"fmt"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/pkg/errors"
)
// AlertState denotes the state of an active alert.
type AlertState int
// The enum values are ordered by priority (lowest to highest).
// When determining overall rule state, higher numeric values take precedence.
const (
StateInactive AlertState = iota
StatePending
StateRecovering
StateFiring
StateNoData
StateDisabled
)
func (s AlertState) String() string {
switch s {
case StateInactive:
return "inactive"
case StatePending:
return "pending"
case StateFiring:
return "firing"
case StateNoData:
return "nodata"
case StateDisabled:
return "disabled"
case StateRecovering:
return "recovering"
}
panic(errors.Errorf("unknown alert state: %d", s))
}
func (s AlertState) MarshalJSON() ([]byte, error) {
return json.Marshal(s.String())
}
func (s *AlertState) UnmarshalJSON(b []byte) error {
var v interface{}
if err := json.Unmarshal(b, &v); err != nil {
return err
}
switch value := v.(type) {
case string:
switch value {
case "inactive":
*s = StateInactive
case "pending":
*s = StatePending
case "firing":
*s = StateFiring
case "nodata":
*s = StateNoData
case "disabled":
*s = StateDisabled
case "recovering":
*s = StateRecovering
default:
*s = StateInactive
}
return nil
default:
return errors.New("invalid alert state")
}
}
func (s *AlertState) Scan(value interface{}) error {
v, ok := value.(string)
if !ok {
return errors.New("invalid alert state")
}
switch v {
case "inactive":
*s = StateInactive
case "pending":
*s = StatePending
case "firing":
*s = StateFiring
case "nodata":
*s = StateNoData
case "disabled":
*s = StateDisabled
case "recovering":
*s = StateRecovering
}
return nil
}
func (s *AlertState) Value() (driver.Value, error) {
return s.String(), nil
}
type LabelsString string
func (l *LabelsString) MarshalJSON() ([]byte, error) {
lbls := make(map[string]string)
err := json.Unmarshal([]byte(*l), &lbls)
if err != nil {
return nil, err
}
return json.Marshal(lbls)
}
func (l *LabelsString) Scan(src interface{}) error {
if data, ok := src.(string); ok {
*l = LabelsString(data)
}
return nil
}
func (l LabelsString) String() string {
return string(l)
}
type RuleStateTimeline struct {
Items []RuleStateHistory `json:"items"`
Total uint64 `json:"total"`
Labels map[string][]string `json:"labels"`
}
type RuleStateHistory struct {
RuleID string `json:"ruleID" ch:"rule_id"`
RuleName string `json:"ruleName" ch:"rule_name"`
// One of ["normal", "firing"]
OverallState AlertState `json:"overallState" ch:"overall_state"`
OverallStateChanged bool `json:"overallStateChanged" ch:"overall_state_changed"`
// One of ["normal", "firing", "nodata", "muted"]
State AlertState `json:"state" ch:"state"`
StateChanged bool `json:"stateChanged" ch:"state_changed"`
UnixMilli int64 `json:"unixMilli" ch:"unix_milli"`
Labels LabelsString `json:"labels" ch:"labels"`
Fingerprint uint64 `json:"fingerprint" ch:"fingerprint"`
Value float64 `json:"value" ch:"value"`
RelatedTracesLink string `json:"relatedTracesLink"`
RelatedLogsLink string `json:"relatedLogsLink"`
}
type QueryRuleStateHistory struct {
Start int64 `json:"start"`
End int64 `json:"end"`
State string `json:"state"`
Filters *v3.FilterSet `json:"filters"`
Offset int64 `json:"offset"`
Limit int64 `json:"limit"`
Order string `json:"order"`
}
func (r *QueryRuleStateHistory) Validate() error {
if r.Start == 0 || r.End == 0 {
return fmt.Errorf("start and end are required")
}
if r.Offset < 0 || r.Limit < 0 {
return fmt.Errorf("offset and limit must be greater than 0")
}
if r.Order != "asc" && r.Order != "desc" {
return fmt.Errorf("order must be asc or desc")
}
return nil
}
type RuleStateHistoryContributor struct {
Fingerprint uint64 `json:"fingerprint" ch:"fingerprint"`
Labels LabelsString `json:"labels" ch:"labels"`
Count uint64 `json:"count" ch:"count"`
RelatedTracesLink string `json:"relatedTracesLink"`
RelatedLogsLink string `json:"relatedLogsLink"`
}
type RuleStateTransition struct {
RuleID string `json:"ruleID" ch:"rule_id"`
State AlertState `json:"state" ch:"state"`
FiringTime int64 `json:"firingTime" ch:"firing_time"`
ResolutionTime int64 `json:"resolutionTime" ch:"resolution_time"`
}
type ReleStateItem struct {
State AlertState `json:"state"`
Start int64 `json:"start"`
End int64 `json:"end"`
}
type Stats struct {
TotalCurrentTriggers uint64 `json:"totalCurrentTriggers"`
TotalPastTriggers uint64 `json:"totalPastTriggers"`
CurrentTriggersSeries *v3.Series `json:"currentTriggersSeries"`
PastTriggersSeries *v3.Series `json:"pastTriggersSeries"`
CurrentAvgResolutionTime string `json:"currentAvgResolutionTime"`
PastAvgResolutionTime string `json:"pastAvgResolutionTime"`
CurrentAvgResolutionTimeSeries *v3.Series `json:"currentAvgResolutionTimeSeries"`
PastAvgResolutionTimeSeries *v3.Series `json:"pastAvgResolutionTimeSeries"`
}

View File

@@ -85,18 +85,6 @@ func PromQLResources(coretypes.ExtractorContext) ([]coretypes.ResourceWithID, er
}}, nil
}
func BuilderQueryWithFilterExpressionResources(resource coretypes.Resource, bodyPath string) coretypes.ResourceExtractor {
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
return builderResources(qbtypes.QueryTypeBuilder.StringValue(), resource, gjson.GetBytes(ec.RequestBody, bodyPath).String(), nil)
}
}
func BuilderQueryWildcardResources(resource coretypes.Resource) coretypes.ResourceExtractor {
return func(coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
return builderResources(qbtypes.QueryTypeBuilder.StringValue(), resource, "", nil)
}
}
func resourcesForQuery(query gjson.Result, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
queryType := query.Get("type").String()
typeWildcard := queryType + "/" + coretypes.WildCardSelectorString
@@ -132,11 +120,7 @@ func resourcesForBuilderQuery(queryType string, spec gjson.Result, variables map
}
func builderQueryResourceRefs(queryType string, resource coretypes.Resource, spec gjson.Result, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
return builderResources(queryType, resource, spec.Get("filter.expression").String(), variables)
}
func builderResources(queryType string, resource coretypes.Resource, expression string, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
ids, err := builderQuerySelectors(queryType, expression, variables)
ids, err := builderQuerySelectors(queryType, spec.Get("filter.expression").String(), variables)
if err != nil {
return nil, err
}

View File

@@ -195,46 +195,3 @@ func TestTelemetrySelector(t *testing.T) {
// a value containing "/" stays one logical segment (SplitN 3).
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a/b", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a/b"))
}
func TestBuilderQueryWithFilterExpressionResources(t *testing.T) {
testCases := []struct {
name string
body string
expected []coretypes.ResourceWithID
}{
{
name: "FilterPresent_ScopedID",
body: `{"start":1,"end":2,"filter":{"expression":"signoz.workspace.key.id = 'key-a'"}}`,
expected: []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/key-a"}},
},
{
name: "FilterAbsent_Wildcard",
body: `{"start":1,"end":2}`,
expected: []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/*"}},
},
{
name: "EmptyBody_Wildcard",
body: ``,
expected: []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/*"}},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
refs, err := BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceTraces, "filter.expression")(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
require.NoError(t, err)
assert.Equal(t, testCase.expected, refs)
})
}
t.Run("InvalidExpression_Error", func(t *testing.T) {
_, err := BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceTraces, "filter.expression")(coretypes.ExtractorContext{RequestBody: []byte(`{"filter":{"expression":"signoz.workspace.key.id = "}}`)})
require.Error(t, err)
})
}
func TestBuilderQueryWildcardResources(t *testing.T) {
refs, err := BuilderQueryWildcardResources(coretypes.ResourceTelemetryResourceTraces)(coretypes.ExtractorContext{})
require.NoError(t, err)
assert.Equal(t, []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/*"}}, refs)
}

View File

@@ -155,7 +155,7 @@ func NewModules(
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
MetricReductionRule: metricReductionRule,
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore, providerSettings),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
ServiceAccount: serviceAccount,
ServiceAccountGetter: serviceAccountGetter,
LogsPipeline: impllogspipeline.NewModule(sqlstore),

View File

@@ -38,9 +38,8 @@ var logsBodyPromotedEntry = promotetypes.NewLogsBodyTarget().Entry
var logsBodyIndexLookup = promotetypes.NewLogsBodyTarget().JSONIndexLookup()
// ClickHouse stores a `col.path::Type` skip index expression as CAST(col.path, 'Type');
// a backtick-quoted path may hold any character, parentheses included.
var simpleJSONSubColumnIndexExprRe = regexp.MustCompile("^CAST\\((?P<expr>(?:[^()`]|`[^`]*`)+), '(?P<type>.+)'\\)$")
// ClickHouse stores a `col.path::Type` skip index expression as CAST(col.path, 'Type').
var simpleJSONSubColumnIndexExprRe = regexp.MustCompile(`^CAST\((?P<expr>[^()]+), '(?P<type>.+)'\)$`)
// unfoldJSONSubColumnIndexExpr accepts both the folded (lower/assumeNotNull)
// and the bare type-cast expression forms.
@@ -199,8 +198,7 @@ func (t *telemetryMetaStore) ListJSONIndexes(ctx context.Context, lookup telemet
columnExpr, columnType, err := unfoldJSONSubColumnIndexExpr(expr)
if err != nil {
t.logger.WarnContext(ctx, "skipping JSON sub column index with unrecognized expression", slog.String("index", name), slog.String("expr", expr), errors.Attr(err))
continue
return nil, errors.WrapInternalf(err, CodeFailLoadLogsJSONIndexes, "failed to unfold JSON sub column index expression: %s", expr)
}
fdt, found := telemetrytypes.MappingJSONDataTypeToFieldDataType[columnType]

View File

@@ -118,76 +118,6 @@ func NewTelemetryMetaStore(
return t
}
// GetMaterializedKeys sets each key's Indexes to the skip indexes built on its column alone.
func (t *telemetryMetaStore) GetMaterializedKeys(ctx context.Context, signal telemetrytypes.Signal) ([]*telemetrytypes.TelemetryFieldKey, error) {
var keys []*telemetrytypes.TelemetryFieldKey
var dbName, localTableName string
var err error
switch signal {
case telemetrytypes.SignalTraces:
keys, err = t.tracesTblStatementToFieldKeys(ctx)
dbName, localTableName = t.tracesDBName, tracestelemetryschema.SpanIndexV3LocalTableName
case telemetrytypes.SignalLogs:
keys, err = t.logsTblStatementToFieldKeys(ctx)
dbName, localTableName = t.logsDBName, logstelemetryschema.LogsV2LocalTableName
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "materialized keys are not supported for signal %s", signal.StringValue())
}
if err != nil {
return nil, err
}
indexes, err := t.listColumnSkipIndexes(ctx, signal, dbName, localTableName)
if err != nil {
return nil, err
}
for _, key := range keys {
for _, index := range indexes[strings.Trim(telemetrytypes.FieldKeyToMaterializedColumnName(key), "`")] {
index.Name = key.Name
index.FieldContext = key.FieldContext
index.FieldDataType = key.FieldDataType
key.Indexes = append(key.Indexes, index)
}
}
return keys, nil
}
// listColumnSkipIndexes keys each index by its expression with one pair of
// quoting backticks removed, so only an index on a single column matches a
// column name.
func (t *telemetryMetaStore) listColumnSkipIndexes(ctx context.Context, signal telemetrytypes.Signal, dbName, localTableName string) (map[string][]telemetrytypes.TelemetryFieldKeySkipIndex, error) {
ctx = withTelemetryContext(ctx, signal, "listColumnSkipIndexes")
sb := sqlbuilder.Select("name", "type_full", "expr", "granularity").From(SkipIndexTableName)
sb.Where(sb.Equal("database", dbName), sb.Equal("table", localTableName))
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to load skip indexes of %s.%s", dbName, localTableName)
}
defer rows.Close()
indexes := map[string][]telemetrytypes.TelemetryFieldKeySkipIndex{}
for rows.Next() {
var name, typeFull, expr string
var granularity uint64
if err := rows.Scan(&name, &typeFull, &expr, &granularity); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan skip index")
}
column := strings.TrimSuffix(strings.TrimPrefix(expr, "`"), "`")
indexes[column] = append(indexes[column], telemetrytypes.TelemetryFieldKeySkipIndex{
BaseColumn: column,
IndexName: name,
IndexType: typeFull,
IndexExpression: expr,
Granularity: int(granularity),
})
}
return indexes, rows.Err()
}
// tracesTblStatementToFieldKeys returns materialised attribute/resource/scope keys from the traces table.
func (t *telemetryMetaStore) tracesTblStatementToFieldKeys(ctx context.Context) ([]*telemetrytypes.TelemetryFieldKey, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{

View File

@@ -17,10 +17,8 @@ const (
GenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
GenAIUsageReasoningOutputTokens = "gen_ai.usage.reasoning.output_tokens"
GenAIInputMessages = "gen_ai.input.messages"
GenAIOutputMessages = "gen_ai.output.messages"
GenAISystemInstructions = "gen_ai.system_instructions"
GenAIResponseFinishReasons = "gen_ai.response.finish_reasons"
GenAIInputMessages = "gen_ai.input.messages"
GenAIOutputMessages = "gen_ai.output.messages"
GenAIToolCallID = "gen_ai.tool.call.id"
GenAIToolCallArguments = "gen_ai.tool.call.arguments"

View File

@@ -1,6 +1,5 @@
// The message types mirror the OpenTelemetry GenAI message schemas, one type per schema definition:
// https://github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/model/gen-ai/gen-ai-input-messages.json
// https://github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/model/gen-ai/gen-ai-output-messages.json
// open-telemetry/semantic-conventions-genai@06ec68e, model/gen-ai/gen-ai-{input,output}-messages.json.
// Keys the schema does not name are dropped; the raw attribute on the span still has them.
package aiobservabilitytypes

View File

@@ -4,45 +4,25 @@ import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
FormatterSemconv = "semconv"
FormatterOpenInference = "openinference"
FormatterLangChain = "langchain"
FormatterOpenRouter = "openrouter"
FormatterAnthropic = "anthropic"
FormatterGemini = "gemini"
FormatterOpenAIChat = "openai.chat"
FormatterOpenAIResponses = "openai.responses"
FormatterText = "text"
FormatterGeneric = "generic"
FormatterSemconv = "semconv"
FormatterOpenAIChat = "openai.chat"
FormatterText = "text"
FormatterGeneric = "generic"
)
var dataURL = regexp.MustCompile(`^data:([^;,]+);base64,(.+)$`)
// Provider spellings of the finish reasons; the enum values themselves pass through. tool_calls and
// function_call are OpenAI's, completed, failed, cancelled, incomplete and max_output_tokens the Responses
// status and incomplete reason, end_turn, stop_sequence, max_tokens, tool_use, refusal and
// model_context_window_exceeded Anthropic's, the upper-case ones Gemini's, tool-calls and content-filter
// the Vercel AI SDK's:
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L44782
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L69691
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/stop_reason.py#L5
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L510
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-finish-reason.ts
// Provider spellings of the finish reasons; the enum values themselves pass through.
var finishReasonAliases = map[string]FinishReason{
"tool_calls": FinishReasonToolCall, "function_call": FinishReasonToolCall, "tool-calls": FinishReasonToolCall, "tool_use": FinishReasonToolCall,
"end_turn": FinishReasonStop, "stop_sequence": FinishReasonStop, "completed": FinishReasonStop,
"max_tokens": FinishReasonLength, "max_output_tokens": FinishReasonLength, "model_context_window_exceeded": FinishReasonLength,
"content-filter": FinishReasonContentFilter, "refusal": FinishReasonContentFilter, "safety": FinishReasonContentFilter, "recitation": FinishReasonContentFilter,
"blocklist": FinishReasonContentFilter, "prohibited_content": FinishReasonContentFilter, "spii": FinishReasonContentFilter, "image_safety": FinishReasonContentFilter,
"failed": FinishReasonError, "cancelled": FinishReasonError, "incomplete": FinishReasonError, "malformed_function_call": FinishReasonError,
"tool_calls": FinishReasonToolCall, "function_call": FinishReasonToolCall,
"end_turn": FinishReasonStop, "stop_sequence": FinishReasonStop,
"max_tokens": FinishReasonLength,
}
// FormattedMessages is the OTel GenAI view of one span's messages; Warnings holds what the converter could
@@ -55,42 +35,19 @@ type FormattedMessages struct {
// FormatMessages builds the OTel GenAI view of a span's messages from the formats SDKs write. attributes
// is the span's JSON attribute document, nested by key segment, so an object value is whole. Each
// message value is a JSON string or a structured value. An embeddings call has no messages, whatever the
// mapper moved onto the message attributes. OpenInference's flattened messages come first,
// then a tool call and its result, then the AI SDK's ai.* keys, then the message attributes, then
// openllmetry's flattened prompts and completions. gen_ai.system_instructions leads the
// input and gen_ai.response.finish_reasons overrides the finish reasons the converter found, whichever
// converter ran.
// message value is a JSON string or a structured value. A tool call and its result take precedence over
// messages beside them.
func FormatMessages(attributes map[string]any) FormattedMessages {
f := &formatting{attributes: attributes, out: FormattedMessages{Input: InputMessages{}, Output: OutputMessages{}, Warnings: []string{}}}
if isEmbeddings(attributes) {
return f.out
}
switch {
case f.openInference():
case attribute(f.attributes, GenAIToolCallArguments) != nil || attribute(f.attributes, GenAIToolCallResult) != nil:
f.tool()
case f.vercel():
case attribute(f.attributes, GenAIInputMessages) != nil || attribute(f.attributes, GenAIOutputMessages) != nil:
f.messages()
case f.traceloop():
}
f.prependSystemInstructions()
f.fillFinishReasons()
return f.out
}
// isEmbeddings matches the operation name as the semconv embeddings, openllmetry's llm.request.type
// embedding and OpenInference's span kind EMBEDDING, copied by the mapper, or the AI SDK's ai.embed* ids.
// https://github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/model/gen-ai/registry.yaml#L658
func isEmbeddings(attributes map[string]any) bool {
switch strings.ToLower(stringOf(attribute(attributes, GenAIOperationName))) {
case "embeddings", "embedding":
return true
}
return strings.HasPrefix(stringOf(attribute(attributes, vercelOperation)), "ai.embed")
}
type formatting struct {
attributes map[string]any
out FormattedMessages
@@ -106,104 +63,32 @@ func (f *formatting) messages() {
if input := attribute(f.attributes, GenAIInputMessages); input != nil {
var msgs OutputMessages
msgs, inputLabel = f.side(decode(input), RoleUser)
f.out.Input = inputMessages(msgs)
for _, m := range msgs {
f.out.Input = append(f.out.Input, ChatMessage{Role: m.Role, Parts: m.Parts, Name: m.Name})
}
}
if output := attribute(f.attributes, GenAIOutputMessages); output != nil {
f.out.Output, outputLabel = f.side(decode(output), RoleAssistant)
f.completionToolCalls()
}
f.warnMixedFormats(inputLabel, outputLabel)
}
// OpenRouter's answer object or an Anthropic response beside the OpenAI chat request it answers is one
// format, not a mix: an Anthropic request without system and without Anthropic-only blocks reads as an
// OpenAI chat request.
func (f *formatting) warnMixedFormats(inputLabel, outputLabel string) {
switch {
case inputLabel == "" || outputLabel == "" || outputLabel == inputLabel:
case inputLabel == FormatterOpenAIChat && (outputLabel == FormatterOpenRouter || outputLabel == FormatterAnthropic):
default:
if inputLabel != "" && outputLabel != "" && outputLabel != inputLabel {
f.warn("output formatted as %s, input as %s", outputLabel, inputLabel)
}
}
// prependSystemInstructions adds gen_ai.system_instructions, a list of parts, as a leading system message,
// unless the input already starts with a system message of the same text: the OpenInference strands
// processor keeps the attribute and inserts it as message 0 as well. A span without messages, such as
// create_agent, stays without them.
// https://github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/model/gen-ai/gen-ai-system-instructions.json
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-strands-agents/src/openinference/instrumentation/strands_agents/processor.py#L152
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-strands-agents/src/openinference/instrumentation/strands_agents/processor.py#L293
func (f *formatting) prependSystemInstructions() {
raw := attribute(f.attributes, GenAISystemInstructions)
if raw == nil || (len(f.out.Input) == 0 && len(f.out.Output) == 0) {
return
}
parts := systemParts(decode(raw))
if len(parts) == 0 || (len(f.out.Input) > 0 && f.out.Input[0].Role == RoleSystem && textOf(f.out.Input[0].Parts) == textOf(parts)) {
return
}
f.out.Input = append(InputMessages{{Role: RoleSystem, Parts: parts}}, f.out.Input...)
}
// fillFinishReasons applies gen_ai.response.finish_reasons, one entry per output message, over
// whatever the converter found in the payload.
// https://github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/model/gen-ai/registry.yaml#L210
func (f *formatting) fillFinishReasons() {
raw := attribute(f.attributes, GenAIResponseFinishReasons)
if raw == nil || len(f.out.Output) == 0 {
return
}
reasons := stringList(decode(raw))
if len(reasons) != len(f.out.Output) {
f.warn("gen_ai.response.finish_reasons has %d entries for %d output messages", len(reasons), len(f.out.Output))
return
}
for i, reason := range reasons {
if fr := finishReason(reason); fr != nil {
f.out.Output[i].FinishReason = fr
}
}
}
// side converts one attribute value by its shape; role is what a bare string on this side is
// spoken as.
func (f *formatting) side(value any, role Role) (OutputMessages, string) {
switch v := value.(type) {
case []any:
if first, ok := firstObject(v); ok {
switch {
case isGeminiContent(first):
return f.geminiContents(v), FormatterGemini
case first["parts"] != nil:
if _, ok := first["parts"]; ok {
return f.semconv(v), FormatterSemconv
case isAnthropicMessages(v):
return f.anthropicMessages(v), FormatterAnthropic
case first["role"] != nil:
}
if _, ok := first["role"]; ok {
return f.openAIChatMessages(v), FormatterOpenAIChat
}
}
case map[string]any:
switch {
case isLangChainRun(v):
return f.langChainRun(v), FormatterLangChain
case isOpenRouterCompletion(v):
return f.openRouterCompletion(v), FormatterOpenRouter
case isResponsesResponse(v):
return f.responsesResponse(v), FormatterOpenAIResponses
case isResponsesRequest(v):
return f.responsesRequest(v), FormatterOpenAIResponses
case isAnthropicResponse(v):
return f.anthropicResponse(v), FormatterAnthropic
case isAnthropicRequest(v):
return f.anthropicRequest(v), FormatterAnthropic
case isGeminiResponse(v):
return f.geminiResponse(v), FormatterGemini
case isGeminiRequest(v):
return f.geminiRequest(v), FormatterGemini
case isGeminiContent(v):
return OutputMessages{geminiContent(v, role)}, FormatterGemini
}
if choices, ok := v["choices"].([]any); ok {
return f.openAIChatResponse(choices), FormatterOpenAIChat
}
@@ -236,44 +121,6 @@ func attribute(doc map[string]any, key string) any {
return current
}
// indexed reads a flattened list, "<key>.<N>.*" keys stored as an object keyed by N, in numeric order.
// A list value is returned as is.
func indexed(doc map[string]any, key string) []map[string]any {
var items []any
switch v := attribute(doc, key).(type) {
case []any:
items = v
case map[string]any:
indexes := make([]int, 0, len(v))
byIndex := make(map[int]any, len(v))
for k, item := range v {
n, err := strconv.Atoi(k)
if err != nil {
continue
}
indexes = append(indexes, n)
byIndex[n] = item
}
sort.Ints(indexes)
for _, n := range indexes {
items = append(items, byIndex[n])
}
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
if m, ok := item.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// indexedAt reads one entry of a flattened list by its index.
func indexedAt(doc map[string]any, key string, i int) map[string]any {
m, _ := attribute(doc, key+"."+strconv.Itoa(i)).(map[string]any)
return m
}
func firstObject(list []any) (map[string]any, bool) {
if len(list) == 0 {
return nil, false
@@ -286,31 +133,6 @@ func genericMessage(value any) OutputMessage {
return OutputMessage{Parts: Parts{part(GenericPart{"type": FormatterGeneric, "content": stringOf(value)})}}
}
func inputMessages(msgs OutputMessages) InputMessages {
out := make(InputMessages, 0, len(msgs))
for _, m := range msgs {
out = append(out, ChatMessage{Role: m.Role, Parts: m.Parts, Name: m.Name})
}
return out
}
// foldAssistant merges runs of assistant messages into one and drops messages without parts, for
// SDKs that write one message per output item.
func foldAssistant(msgs OutputMessages) OutputMessages {
out := make(OutputMessages, 0, len(msgs))
for _, msg := range msgs {
if len(out) > 0 && msg.Role == RoleAssistant && out[len(out)-1].Role == RoleAssistant {
out[len(out)-1].Parts = append(out[len(out)-1].Parts, msg.Parts...)
continue
}
if len(msg.Parts) == 0 {
continue
}
out = append(out, msg)
}
return out
}
// decode parses a JSON-encoded attribute value, twice when an SDK encoded the string again.
// Anything that is not JSON is returned as is.
func decode(value any) any {
@@ -357,23 +179,6 @@ func finishReason(value string) *FinishReason {
return &fr
}
// stringList reads a list of strings, or a bare string as a list of one.
func stringList(value any) []string {
switch v := value.(type) {
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
out = append(out, stringOf(item))
}
return out
case string:
if v != "" {
return []string{v}
}
}
return nil
}
func optionalString(value any) *string {
s := stringOf(value)
if s == "" {
@@ -390,61 +195,8 @@ func textPart(content string) TextPart {
return TextPart{Type: PartTypeText, Content: content}
}
// textOf joins the text parts, for comparing a system message with gen_ai.system_instructions.
func textOf(parts Parts) string {
var texts []string
for _, p := range parts {
if t, ok := p.Value.(TextPart); ok {
texts = append(texts, t.Content)
}
}
return strings.Join(texts, "\n")
}
// splitToolResults moves tool_call_response parts out of a message into a tool message before it,
// for APIs that carry tool results inside the next user turn.
func splitToolResults(msg OutputMessage) OutputMessages {
results, rest := Parts{}, Parts{}
for _, p := range msg.Parts {
if _, ok := p.Value.(ToolCallResponsePart); ok {
results = append(results, p)
} else {
rest = append(rest, p)
}
}
if len(results) == 0 {
return OutputMessages{msg}
}
out := OutputMessages{{Role: RoleTool, Parts: results}}
if len(rest) > 0 {
msg.Parts = rest
out = append(out, msg)
}
return out
}
// systemParts reads a system prompt given outside the history, a list of parts or a bare string.
func systemParts(value any) Parts {
switch v := value.(type) {
case []any:
parts := make(Parts, 0, len(v))
for _, item := range v {
parts = append(parts, contentPart(item))
}
return parts
case string:
if v != "" {
return Parts{part(textPart(v))}
}
}
return nil
}
// contentPart converts one content block in the OpenAI Chat Completions or Responses spelling, which
// semconv parts may also carry; a text part in the semconv spelling has content where OpenAI has text.
// A block of any other type is kept as sent.
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L53827
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L57589
// contentPart converts one OpenAI content block, which semconv parts may also carry; a block of
// any other type is kept as sent.
func contentPart(item any) Part {
p, ok := item.(map[string]any)
if !ok {
@@ -454,14 +206,11 @@ func contentPart(item any) Part {
return part(GenericPart{"type": FormatterGeneric, "content": stringOf(item)})
}
switch stringOf(p["type"]) {
case "text", "input_text", "output_text", "summary_text":
if p["text"] == nil {
return part(textPart(stringOf(p["content"])))
}
case "text":
return part(textPart(stringOf(p["text"])))
case "refusal":
return part(textPart(stringOf(p["refusal"])))
case "image_url", "input_image":
case "image_url":
url := stringOf(p["image_url"])
if ref, ok := p["image_url"].(map[string]any); ok {
url = stringOf(ref["url"])
@@ -469,20 +218,10 @@ func contentPart(item any) Part {
if url != "" {
return part(mediaPart(url, ModalityImage))
}
if id := stringOf(p["file_id"]); id != "" {
return part(FilePart{Type: PartTypeFile, Modality: ModalityImage, FileID: id})
}
case "file":
if f, ok := filePart(p); ok {
return f
}
case "input_file":
if f, ok := filePart(map[string]any{"file": p}); ok {
return f
}
if url := stringOf(p["file_url"]); url != "" {
return part(mediaPart(url, ModalityDocument))
}
}
return part(GenericPart(p))
}
@@ -500,19 +239,6 @@ func filePart(block map[string]any) (Part, bool) {
return Part{}, false
}
// modalityOf maps a MIME type to the schema modality, document for anything that is not media.
func modalityOf(mimeType string) Modality {
switch {
case strings.HasPrefix(mimeType, "image/"):
return ModalityImage
case strings.HasPrefix(mimeType, "audio/"):
return ModalityAudio
case strings.HasPrefix(mimeType, "video/"):
return ModalityVideo
}
return ModalityDocument
}
// mediaPart places a data URL in a BlobPart, any other URL in a UriPart.
func mediaPart(url string, modality Modality) any {
if m := dataURL.FindStringSubmatch(url); m != nil {

View File

@@ -1,156 +0,0 @@
package aiobservabilitytypes
import "strings"
// Format: Anthropic Messages, a request {system, messages} or a response {content, stop_reason}:
// https://platform.claude.com/docs/en/api/messages
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/message_create_params.py#L181
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/message_param.py#L28
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/message.py#L91
// Written by: gateways recording raw Anthropic bodies. OpenInference flattens these into
// llm.*_messages, which take precedence.
// Handled: string and block content; text, image and document sources of every type, tool_use,
// tool_result, thinking, redacted_thinking, server_tool_use and the *_tool_result blocks. A tool_result
// inside a user turn becomes a tool message before it.
// Not yet: a request without system and without Anthropic-only blocks is indistinguishable from an
// OpenAI chat request and formats as one.
// Block types only the Messages API has, which tell its message list from an OpenAI chat one:
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/tool_use_block_param.py#L23
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/thinking_block_param.py#L21
var anthropicBlockTypes = map[string]bool{
"tool_use": true, "tool_result": true, "thinking": true, "redacted_thinking": true, "server_tool_use": true, "document": true,
}
func isAnthropicRequest(m map[string]any) bool {
messages, ok := m["messages"].([]any)
if !ok {
return false
}
return m["system"] != nil || isAnthropicMessages(messages)
}
func isAnthropicResponse(m map[string]any) bool {
if _, ok := m["content"].([]any); !ok {
return false
}
_, hasStop := m["stop_reason"]
return hasStop || stringOf(m["type"]) == "message"
}
func isAnthropicMessages(list []any) bool {
for _, item := range list {
m, _ := item.(map[string]any)
blocks, _ := m["content"].([]any)
for _, block := range blocks {
b, _ := block.(map[string]any)
typ := stringOf(b["type"])
if anthropicBlockTypes[typ] || strings.HasSuffix(typ, "_tool_result") || (typ == "image" && b["source"] != nil) {
return true
}
}
}
return false
}
func (f *formatting) anthropicRequest(m map[string]any) OutputMessages {
messages, _ := m["messages"].([]any)
msgs := f.anthropicMessages(messages)
if parts := systemParts(m["system"]); len(parts) > 0 {
return append(OutputMessages{{Role: RoleSystem, Parts: parts}}, msgs...)
}
return msgs
}
func (f *formatting) anthropicMessages(list []any) OutputMessages {
out := make(OutputMessages, 0, len(list))
for _, item := range list {
m, ok := item.(map[string]any)
if !ok {
out = append(out, genericMessage(item))
continue
}
msg := OutputMessage{Role: role(stringOf(m["role"])), Parts: anthropicContent(m["content"])}
out = append(out, splitToolResults(msg)...)
}
return out
}
// anthropicResponse is one assistant message with the stop reason.
func (f *formatting) anthropicResponse(m map[string]any) OutputMessages {
msg := OutputMessage{Role: role(stringOf(m["role"])), Parts: anthropicContent(m["content"]), FinishReason: finishReason(stringOf(m["stop_reason"]))}
if msg.Role.IsZero() {
msg.Role = RoleAssistant
}
return OutputMessages{msg}
}
func anthropicContent(content any) Parts {
parts := Parts{}
switch v := content.(type) {
case string:
if v != "" {
parts = append(parts, part(textPart(v)))
}
case []any:
for _, item := range v {
if b, ok := item.(map[string]any); ok {
parts = append(parts, anthropicBlock(b))
} else {
parts = append(parts, contentPart(item))
}
}
}
return parts
}
// anthropicBlock converts one content block:
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/tool_result_block_param.py#L29
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/redacted_thinking_block_param.py#L15
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/server_tool_use_block_param.py#L33
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/web_search_tool_result_block_param.py#L22
func anthropicBlock(b map[string]any) Part {
typ := stringOf(b["type"])
switch {
case typ == "text":
return part(textPart(stringOf(b["text"])))
case typ == "image" || typ == "document":
return anthropicSource(b, typ)
case typ == "tool_use":
return part(ToolCallRequestPart{Type: PartTypeToolCall, ID: optionalString(b["id"]), Name: stringOf(b["name"]), Arguments: b["input"]})
case typ == "tool_result":
return part(ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: optionalString(b["tool_use_id"]), Response: toolResponse(b["content"])})
case typ == "thinking":
return part(ReasoningPart{Type: PartTypeReasoning, Content: stringOf(b["thinking"])})
case typ == "redacted_thinking":
return part(ReasoningPart{Type: PartTypeReasoning})
case typ == "server_tool_use":
return part(ServerToolCallPart{Type: PartTypeServerToolCall, ID: optionalString(b["id"]), Name: stringOf(b["name"]), ServerToolCall: GenericServerToolCall(b)})
case strings.HasSuffix(typ, "_tool_result"):
return part(ServerToolCallResponsePart{Type: PartTypeServerToolCallResponse, ID: optionalString(b["tool_use_id"]), ServerToolCallResponse: GenericServerToolCallResponse(b)})
}
return part(GenericPart(b))
}
// anthropicSource reads the {source: {type: base64|url|file|text, ...}} of an image or document block:
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/image_block_param.py#L14
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/document_block_param.py#L16
func anthropicSource(b map[string]any, typ string) Part {
source, _ := b["source"].(map[string]any)
mime := optionalString(source["media_type"])
modality := ModalityDocument
if typ == "image" {
modality = ModalityImage
}
switch stringOf(source["type"]) {
case "base64":
return part(BlobPart{Type: PartTypeBlob, Modality: modality, MimeType: mime, Content: stringOf(source["data"])})
case "url":
return part(UriPart{Type: PartTypeURI, Modality: modality, MimeType: mime, URI: stringOf(source["url"])})
case "file":
return part(FilePart{Type: PartTypeFile, Modality: modality, MimeType: mime, FileID: stringOf(source["file_id"])})
case "text":
return part(textPart(stringOf(source["data"])))
}
return part(GenericPart(b))
}

View File

@@ -1,90 +0,0 @@
package aiobservabilitytypes
import "testing"
// Cases follow the Anthropic Messages API shapes; no capture in SigNoz/scripts records raw Anthropic
// bodies.
// https://platform.claude.com/docs/en/api/messages
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/message_param.py#L28
func TestFormat_Anthropic(t *testing.T) {
runFormatCases(t, []formatCase{
{
// system as a string; end_turn is stop
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/message_create_params.py#L181
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/stop_reason.py#L5
name: "SystemStringAndText_EndTurn",
input: `{"model":"claude-sonnet-4-5","max_tokens":1024,"system":"You are terse.","messages":[{"role":"user","content":"Hi"}]}`,
output: `{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-5","content":[{"type":"text","text":"Hello."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":3}}`,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are terse."}]},
{"role": "user", "parts": [{"type": "text", "content": "Hi"}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello."}], "finish_reason": "stop"}]`,
},
{
// tool_result sits inside the next user turn, with text beside it
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/tool_result_block_param.py#L29
name: "ToolRound_ToolResultSplitFromUserTurn",
input: `{"messages":[{"role":"user","content":"Weather in Oslo?"},{"role":"assistant","content":[{"type":"text","text":"Checking."},{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"Oslo"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"{\"temp_c\":4}"},{"type":"text","text":"And Bergen?"}]}]}`,
output: `{"type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_2","name":"get_weather","input":{"city":"Bergen"}}],"stop_reason":"tool_use"}`,
wantInput: `[
{"role": "user", "parts": [{"type": "text", "content": "Weather in Oslo?"}]},
{"role": "assistant", "parts": [{"type": "text", "content": "Checking."}, {"type": "tool_call", "id": "toolu_1", "name": "get_weather", "arguments": {"city": "Oslo"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "toolu_1", "response": {"temp_c": 4}}]},
{"role": "user", "parts": [{"type": "text", "content": "And Bergen?"}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "id": "toolu_2", "name": "get_weather", "arguments": {"city": "Bergen"}}], "finish_reason": "tool_call"}]`,
},
{
// system as text blocks; every image and document source type; thinking blocks in the history and
// the answer
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/image_block_param.py#L14
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/document_block_param.py#L16
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/thinking_block_param.py#L21
name: "MediaSourcesAndThinking_MaxTokens",
input: `{"system":[{"type":"text","text":"Describe inputs.","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBORw0KGgo="}},{"type":"image","source":{"type":"url","url":"https://example.com/a.jpg"}},{"type":"document","source":{"type":"file","file_id":"file_1"}},{"type":"document","source":{"type":"text","media_type":"text/plain","data":"plain notes"},"title":"notes"},{"type":"text","text":"What are these?"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"Two images and two documents.","signature":"sig"},{"type":"redacted_thinking","data":"xx"},{"type":"text","text":"Images and documents."}]}]}`,
output: `{"type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Listing them.","signature":"sig"},{"type":"text","text":"A PNG, a JPEG"}],"stop_reason":"max_tokens"}`,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "Describe inputs."}]},
{"role": "user", "parts": [
{"type": "blob", "modality": "image", "mime_type": "image/png", "content": "iVBORw0KGgo="},
{"type": "uri", "modality": "image", "uri": "https://example.com/a.jpg"},
{"type": "file", "modality": "document", "file_id": "file_1"},
{"type": "text", "content": "plain notes"},
{"type": "text", "content": "What are these?"}
]},
{"role": "assistant", "parts": [{"type": "reasoning", "content": "Two images and two documents."}, {"type": "reasoning", "content": ""}, {"type": "text", "content": "Images and documents."}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "reasoning", "content": "Listing them."}, {"type": "text", "content": "A PNG, a JPEG"}], "finish_reason": "length"}]`,
},
{
// server_tool_use with its web_search_tool_result in the same message; refusal is a content filter
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/server_tool_use_block_param.py#L33
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/web_search_tool_result_block_param.py#L22
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/stop_reason.py#L5
name: "ServerToolUseAndResult_Refusal",
output: `{"type":"message","role":"assistant","content":[{"type":"server_tool_use","id":"srvtoolu_1","name":"web_search","input":{"query":"otel genai"}},{"type":"web_search_tool_result","tool_use_id":"srvtoolu_1","content":[{"type":"web_search_result","url":"https://opentelemetry.io","title":"OTel"}]},{"type":"text","text":"Found it."}],"stop_reason":"refusal"}`,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "server_tool_call", "id": "srvtoolu_1", "name": "web_search", "server_tool_call": {"type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": {"query": "otel genai"}}},
{"type": "server_tool_call_response", "id": "srvtoolu_1", "server_tool_call_response": {"type": "web_search_tool_result", "tool_use_id": "srvtoolu_1", "content": [{"type": "web_search_result", "url": "https://opentelemetry.io", "title": "OTel"}]}},
{"type": "text", "content": "Found it."}
], "finish_reason": "content_filter"}]`,
},
{
// a request with neither system nor Anthropic-only blocks is an OpenAI chat request; the
// response names the format
name: "PlainRequest_ResponseNamesFormat",
input: `{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"Hi"}]}`,
output: `{"type":"message","role":"assistant","content":[{"type":"text","text":"Hello."}],"stop_reason":"end_turn"}`,
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "Hi"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello."}], "finish_reason": "stop"}]`,
},
{
name: "BareMessageList_ToolBlocksNameFormat",
input: `[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":[{"type":"text","text":"sunny"}]}]}]`,
wantInput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "id": "toolu_1", "response": "sunny"}]}]`,
wantOutput: `[]`,
},
})
}

View File

@@ -1,165 +0,0 @@
package aiobservabilitytypes
// Format: Gemini generateContent, a request {contents, systemInstruction} or a response {candidates} of
// Content {role, parts} objects, in the REST camelCase or the SDK snake_case spelling:
// https://ai.google.dev/api/generate-content
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L2303
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L2626
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L8351
// Written by: gateways recording raw Gemini bodies. OpenInference flattens these into llm.*_messages,
// which take precedence.
// Handled: text, thought text, inlineData, fileData, functionCall, functionResponse; a functionResponse
// inside a user turn becomes a tool message before it. Any other part is kept as sent.
// Not yet: a blocked prompt, promptFeedback without candidates.
func isGeminiRequest(m map[string]any) bool {
switch m["contents"].(type) {
case []any, string:
return true
}
return false
}
func isGeminiResponse(m map[string]any) bool {
_, ok := m["candidates"].([]any)
return ok
}
// isGeminiContent tells a bare Content from a semconv message: both carry parts, only semconv parts
// have a type.
func isGeminiContent(m map[string]any) bool {
parts, ok := m["parts"].([]any)
if !ok {
return false
}
first, ok := firstObject(parts)
return ok && first["type"] == nil
}
// geminiRequest reads contents and systemInstruction:
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L6527
func (f *formatting) geminiRequest(m map[string]any) OutputMessages {
var msgs OutputMessages
if system := geminiSystemInstruction(objectAt(m, "systemInstruction", "system_instruction")); system != nil {
msgs = append(msgs, *system)
}
switch contents := m["contents"].(type) {
case string:
msgs = append(msgs, OutputMessage{Role: RoleUser, Parts: Parts{part(textPart(contents))}})
case []any:
msgs = append(msgs, f.geminiContents(contents)...)
}
return msgs
}
// geminiResponse is one message per candidate with its finish reason.
func (f *formatting) geminiResponse(m map[string]any) OutputMessages {
candidates, _ := m["candidates"].([]any)
out := make(OutputMessages, 0, len(candidates))
for _, item := range candidates {
candidate, ok := item.(map[string]any)
if !ok {
out = append(out, genericMessage(item))
continue
}
content, _ := candidate["content"].(map[string]any)
msg := geminiContent(content, RoleAssistant)
msg.FinishReason = finishReason(stringOf(objectAt(candidate, "finishReason", "finish_reason")))
out = append(out, msg)
}
return out
}
// geminiSystemInstruction accepts a string, a Content or a list of parts.
func geminiSystemInstruction(value any) *OutputMessage {
var parts Parts
switch v := value.(type) {
case map[string]any:
parts = geminiContent(v, RoleSystem).Parts
case []any:
parts = geminiParts(v)
default:
parts = systemParts(value)
}
if len(parts) == 0 {
return nil
}
return &OutputMessage{Role: RoleSystem, Parts: parts}
}
func (f *formatting) geminiContents(list []any) OutputMessages {
out := make(OutputMessages, 0, len(list))
for _, item := range list {
switch v := item.(type) {
case string:
out = append(out, OutputMessage{Role: RoleUser, Parts: Parts{part(textPart(v))}})
case map[string]any:
out = append(out, splitToolResults(geminiContent(v, RoleUser))...)
default:
out = append(out, genericMessage(item))
}
}
return out
}
func geminiContent(c map[string]any, fallback Role) OutputMessage {
msg := OutputMessage{Role: role(stringOf(c["role"])), Parts: Parts{}}
if msg.Role.IsZero() {
msg.Role = fallback
}
parts, _ := c["parts"].([]any)
msg.Parts = geminiParts(parts)
return msg
}
func geminiParts(list []any) Parts {
parts := Parts{}
for _, item := range list {
if p, ok := item.(map[string]any); ok {
parts = append(parts, geminiPart(p))
} else {
parts = append(parts, contentPart(item))
}
}
return parts
}
// geminiPart converts one Part by the one field it carries:
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L2324
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L2112
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L1731
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L1830
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L2032
func geminiPart(p map[string]any) Part {
if text, ok := p["text"].(string); ok {
if thought, _ := p["thought"].(bool); thought {
return part(ReasoningPart{Type: PartTypeReasoning, Content: text})
}
return part(textPart(text))
}
if data, ok := objectAt(p, "inlineData", "inline_data").(map[string]any); ok {
mime := stringOf(objectAt(data, "mimeType", "mime_type"))
return part(BlobPart{Type: PartTypeBlob, Modality: modalityOf(mime), MimeType: optionalString(mime), Content: stringOf(data["data"])})
}
if file, ok := objectAt(p, "fileData", "file_data").(map[string]any); ok {
mime := stringOf(objectAt(file, "mimeType", "mime_type"))
return part(UriPart{Type: PartTypeURI, Modality: modalityOf(mime), MimeType: optionalString(mime), URI: stringOf(objectAt(file, "fileUri", "file_uri"))})
}
if call, ok := objectAt(p, "functionCall", "function_call").(map[string]any); ok {
return part(ToolCallRequestPart{Type: PartTypeToolCall, ID: optionalString(call["id"]), Name: stringOf(call["name"]), Arguments: call["args"]})
}
if response, ok := objectAt(p, "functionResponse", "function_response").(map[string]any); ok {
return part(ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: optionalString(response["id"]), Response: response["response"]})
}
return part(GenericPart(p))
}
// objectAt reads the first of the key spellings that is set.
func objectAt(m map[string]any, keys ...string) any {
for _, key := range keys {
if v, ok := m[key]; ok && v != nil {
return v
}
}
return nil
}

View File

@@ -1,75 +0,0 @@
package aiobservabilitytypes
import "testing"
// Cases follow the Gemini generateContent shapes, REST camelCase and SDK snake_case; no capture in
// SigNoz/scripts records raw Gemini bodies.
// https://ai.google.dev/api/generate-content
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L2303
func TestFormat_Gemini(t *testing.T) {
runFormatCases(t, []formatCase{
{
// systemInstruction as a Content; STOP is stop
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L6527
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L510
name: "SystemInstructionContent_CandidateText_Stop",
input: `{"contents":[{"role":"user","parts":[{"text":"Hi"}]}],"systemInstruction":{"parts":[{"text":"You are terse."}]},"generationConfig":{"temperature":0}}`,
output: `{"candidates":[{"content":{"parts":[{"text":"Hello."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":2},"modelVersion":"gemini-2.5-flash"}`,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are terse."}]},
{"role": "user", "parts": [{"type": "text", "content": "Hi"}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello."}], "finish_reason": "stop"}]`,
},
{
// SDK snake_case spelling; the function response sits in the next user turn
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L1830
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L2032
name: "FunctionRoundSnakeCase_ResponseSplitFromUserTurn",
input: `{"model":"gemini-2.5-pro","contents":[{"role":"user","parts":[{"text":"Weather in Oslo?"}]},{"role":"model","parts":[{"function_call":{"name":"get_weather","args":{"city":"Oslo"}}}]},{"role":"user","parts":[{"function_response":{"name":"get_weather","response":{"temp_c":4}}},{"text":"And Bergen?"}]}],"system_instruction":"Use tools."}`,
output: `{"candidates":[{"content":{"role":"model","parts":[{"function_call":{"id":"fc_1","name":"get_weather","args":{"city":"Bergen"}}}]},"finish_reason":"STOP"}]}`,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "Use tools."}]},
{"role": "user", "parts": [{"type": "text", "content": "Weather in Oslo?"}]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "arguments": {"city": "Oslo"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "response": {"temp_c": 4}}]},
{"role": "user", "parts": [{"type": "text", "content": "And Bergen?"}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "id": "fc_1", "name": "get_weather", "arguments": {"city": "Bergen"}}], "finish_reason": "stop"}]`,
},
{
// inline and file data, a thought part, an executable code part kept as sent; two candidates, the
// second stopped for SAFETY
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L2112
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L1731
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L2344
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L510
name: "MediaAndThought_TwoCandidates",
input: `{"contents":[{"parts":[{"inlineData":{"mimeType":"image/png","data":"iVBORw0KGgo="}},{"fileData":{"mimeType":"video/mp4","fileUri":"gs://bucket/a.mp4"}},{"text":"Describe."}]}]}`,
output: `{"candidates":[{"content":{"role":"model","parts":[{"text":"Looking.","thought":true},{"executableCode":{"language":"PYTHON","code":"print(1)"}},{"text":"A logo and a clip."}]},"finishReason":"MAX_TOKENS"},{"content":{"role":"model","parts":[]},"finishReason":"SAFETY"}]}`,
wantInput: `[{"role": "user", "parts": [
{"type": "blob", "modality": "image", "mime_type": "image/png", "content": "iVBORw0KGgo="},
{"type": "uri", "modality": "video", "mime_type": "video/mp4", "uri": "gs://bucket/a.mp4"},
{"type": "text", "content": "Describe."}
]}]`,
wantOutput: `[
{"role": "assistant", "parts": [{"type": "reasoning", "content": "Looking."}, {"executableCode": {"language": "PYTHON", "code": "print(1)"}}, {"type": "text", "content": "A logo and a clip."}], "finish_reason": "length"},
{"role": "assistant", "parts": [], "finish_reason": "content_filter"}
]`,
},
{
name: "StringContents_UserText",
input: `{"model":"gemini-2.5-flash","contents":"Hi"}`,
output: `{"role":"model","parts":[{"text":"Hello."}]}`,
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "Hi"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello."}]}]`,
},
{
// a bare Content list is told from semconv messages by its untyped parts
name: "BareContentList_NotSemconv",
input: `[{"role":"user","parts":[{"text":"Hi"}]}]`,
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "Hi"}]}]`,
wantOutput: `[]`,
},
})
}

View File

@@ -1,200 +0,0 @@
package aiobservabilitytypes
import "github.com/SigNoz/signoz/pkg/valuer"
// Format: LangChain's serialised run inputs and outputs, {messages: [[Serializable]]} and
// {generations: [[{text, generation_info, message}]]}, each message {lc: 1, type: "constructor",
// id: [..., class], kwargs: {content, tool_calls, tool_call_id, name, ...}}:
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/load/serializable.py#L297
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/outputs/llm_result.py#L23
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/messages/ai.py#L170
// Written by: the LangSmith OTel exporter as gen_ai.prompt and gen_ai.completion, which the span mapper
// moves onto gen_ai.input.messages and gen_ai.output.messages:
// https://github.com/langchain-ai/langsmith-sdk/blob/bf325f3a0d9f415096a4632099b7faff355cde53/python/langsmith/_internal/otel/_otel_exporter.py#L761
// https://github.com/langchain-ai/langsmith-sdk/blob/bf325f3a0d9f415096a4632099b7faff355cde53/python/langsmith/_internal/otel/_otel_exporter.py#L843
// The envelope and the message classes are langchain_core's and shared by every partner package; the
// partners differ in the key that carries the finish reason and in the content blocks they keep.
// Handled: string content, langchain_core's standard content blocks and the OpenAI block spellings,
// tool_calls, tool messages, the finish reason of a generation.
// Not yet: the provider-native thinking blocks anthropic, bedrock and google keep in the content, kept
// as sent:
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/partners/anthropic/langchain_anthropic/chat_models.py#L866
// https://github.com/langchain-ai/langchain-aws/blob/910b79b9787eee773cde65bdd9e94266d6cf6f6c/libs/aws/langchain_aws/chat_models/bedrock_converse.py#L401
// https://github.com/langchain-ai/langchain-google/blob/9f72e9a344235a488396ac2c62d360ceb2b1c81c/libs/genai/langchain_google_genai/chat_models.py#L165
// LangChain message classes to roles; ChatMessage carries its own role in kwargs.
var langChainRoles = map[string]Role{
"SystemMessage": RoleSystem, "SystemMessageChunk": RoleSystem,
"HumanMessage": RoleUser, "HumanMessageChunk": RoleUser,
"AIMessage": RoleAssistant, "AIMessageChunk": RoleAssistant,
"ToolMessage": RoleTool, "ToolMessageChunk": RoleTool, "FunctionMessage": RoleTool, "FunctionMessageChunk": RoleTool,
}
// isLangChainRun looks for the generations list or a messages batch of serialised messages.
func isLangChainRun(m map[string]any) bool {
if _, ok := m["generations"].([]any); ok {
return true
}
batches, ok := m["messages"].([]any)
if !ok || len(batches) == 0 {
return false
}
first, ok := batches[0].([]any)
if !ok {
return false
}
msg, ok := firstObject(first)
return ok && msg["lc"] != nil
}
// langChainRun converts the run inputs, every batch of messages in order, or the run outputs, one
// message per generation.
func (f *formatting) langChainRun(m map[string]any) OutputMessages {
var out OutputMessages
if batches, ok := m["messages"].([]any); ok {
for _, batch := range batches {
if list, ok := batch.([]any); ok {
out = append(out, f.langChainMessages(list)...)
}
}
return out
}
batches, _ := m["generations"].([]any)
for _, batch := range batches {
list, _ := batch.([]any)
for _, item := range list {
generation, ok := item.(map[string]any)
if !ok {
out = append(out, genericMessage(item))
continue
}
msg := OutputMessage{Role: RoleAssistant, Parts: Parts{}}
if message, ok := generation["message"].(map[string]any); ok {
msg = f.langChainMessage(message)
} else if text := stringOf(generation["text"]); text != "" {
msg.Parts = Parts{part(textPart(text))}
}
if info, ok := generation["generation_info"].(map[string]any); ok && msg.FinishReason == nil {
msg.FinishReason = finishReason(stringOf(info["finish_reason"]))
}
out = append(out, msg)
}
}
return out
}
func (f *formatting) langChainMessages(list []any) OutputMessages {
out := make(OutputMessages, 0, len(list))
for _, item := range list {
m, ok := item.(map[string]any)
if !ok {
out = append(out, genericMessage(item))
continue
}
out = append(out, f.langChainMessage(m))
}
return out
}
// langChainMessage converts one serialised message. A tool_call or tool_use block in the content repeats
// a call that tool_calls already carries, langchain_core adds the former from the latter and
// langchain-anthropic keeps the provider's block beside it:
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/messages/ai.py#L274
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/partners/anthropic/langchain_anthropic/chat_models.py#L2571
// A failed call leaves the error body in response_metadata.
func (f *formatting) langChainMessage(m map[string]any) OutputMessage {
kwargs, _ := m["kwargs"].(map[string]any)
msg := OutputMessage{Role: langChainRole(m, kwargs), Parts: Parts{}, Name: optionalString(kwargs["name"])}
if id := stringOf(kwargs["tool_call_id"]); msg.Role == RoleTool && id != "" {
msg.Parts = Parts{part(ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: &id, Response: toolResponse(kwargs["content"])})}
return msg
}
calls, _ := kwargs["tool_calls"].([]any)
switch content := kwargs["content"].(type) {
case string:
if content != "" {
msg.Parts = append(msg.Parts, part(textPart(content)))
}
case []any:
for _, item := range content {
if block, ok := item.(map[string]any); ok && len(calls) > 0 && (block["type"] == "tool_call" || block["type"] == "tool_use") {
continue
}
msg.Parts = append(msg.Parts, langChainBlock(item))
}
}
for _, item := range calls {
if call, ok := item.(map[string]any); ok {
msg.Parts = append(msg.Parts, part(ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(call["name"]), ID: optionalString(call["id"]), Arguments: decode(call["args"])}))
}
}
if metadata, ok := kwargs["response_metadata"].(map[string]any); ok {
msg.FinishReason = langChainFinishReason(metadata)
if body, ok := metadata["body"].(map[string]any); ok && body["error"] != nil {
msg.FinishReason = finishReason("error")
}
}
return msg
}
// langChainFinishReason reads the key each partner writes into response_metadata: finish_reason for
// most, stop_reason for anthropic, stopReason for bedrock, done_reason for ollama:
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/partners/anthropic/langchain_anthropic/chat_models.py#L2510
// https://github.com/langchain-ai/langchain-aws/blob/910b79b9787eee773cde65bdd9e94266d6cf6f6c/libs/aws/langchain_aws/chat_models/bedrock_converse.py#L265
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/partners/ollama/langchain_ollama/chat_models.py#L24
func langChainFinishReason(metadata map[string]any) *FinishReason {
for _, key := range []string{"finish_reason", "stop_reason", "stopReason", "done_reason"} {
if fr := finishReason(stringOf(metadata[key])); fr != nil {
return fr
}
}
return nil
}
// langChainBlock converts one content block: langchain_core's standard reasoning and media blocks, else
// the OpenAI spellings contentPart knows:
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/messages/content.py#L456
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/messages/content.py#L498
func langChainBlock(item any) Part {
block, ok := item.(map[string]any)
if !ok {
return contentPart(item)
}
typ := stringOf(block["type"])
switch typ {
case "reasoning":
return part(ReasoningPart{Type: PartTypeReasoning, Content: stringOf(block["reasoning"])})
case "image", "audio", "video", "file":
modality := Modality{valuer.NewString(typ)}
if typ == "file" {
modality = ModalityDocument
}
mime := optionalString(block["mime_type"])
switch {
case stringOf(block["url"]) != "":
return part(mediaPart(stringOf(block["url"]), modality))
case stringOf(block["base64"]) != "":
return part(BlobPart{Type: PartTypeBlob, Modality: modality, MimeType: mime, Content: stringOf(block["base64"])})
case stringOf(block["file_id"]) != "":
return part(FilePart{Type: PartTypeFile, Modality: modality, MimeType: mime, FileID: stringOf(block["file_id"])})
}
}
return contentPart(item)
}
// langChainRole reads the message class from the serialised id path, falling back to the type
// alias in kwargs:
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/messages/chat.py#L18
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/messages/human.py#L29
func langChainRole(m, kwargs map[string]any) Role {
if id, ok := m["id"].([]any); ok && len(id) > 0 {
class := stringOf(id[len(id)-1])
if r, ok := langChainRoles[class]; ok {
return r
}
if class == "ChatMessage" || class == "ChatMessageChunk" {
return role(stringOf(kwargs["role"]))
}
}
return role(stringOf(kwargs["type"]))
}

View File

@@ -1,77 +0,0 @@
package aiobservabilitytypes
import "testing"
// Captured cases are gen_ai.input.messages and gen_ai.output.messages as the collector stored them for
// the SigNoz/scripts langsmith capture, the LangSmith OTel exporter's serialised run inputs and outputs;
// the others follow langchain_core and the partner packages at the linked lines.
func TestFormat_LangChain(t *testing.T) {
runFormatCases(t, []formatCase{
{
// langsmith capture, ChatOpenAI span after the tool ran
name: "SerialisedMessages_RolesFromClasses_ToolCallAndToolMessage",
input: `{"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":{"finish_reason":"tool_calls","logprobs":null},"type":"ai","id":"lc_run--019e844a","tool_calls":[{"name":"get_weather","args":{"city":"Bengaluru"},"id":"call_nbrbkc5fom95q8uMA2bCvH6s","type":"tool_call"}],"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"}}]]}`,
output: `{"generations":[[{"text":"The weather in Bengaluru is clear, 18°C.","generation_info":{"finish_reason":"stop","logprobs":null},"type":"ChatGeneration","message":{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"The weather in Bengaluru is clear, 18°C.","additional_kwargs":{"refusal":null},"response_metadata":{"finish_reason":"stop","model_name":"gpt-4o-mini-2024-07-18"},"type":"ai","tool_calls":[],"invalid_tool_calls":[]}}}]],"llm_output":{"model_name":"gpt-4o-mini-2024-07-18"},"run":null,"type":"LLMResult"}`,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are a weather assistant. Call get_weather once per city."}]},
{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Bengaluru?"}]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_nbrbkc5fom95q8uMA2bCvH6s", "arguments": {"city": "Bengaluru"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_nbrbkc5fom95q8uMA2bCvH6s", "response": {"city": "Bengaluru", "temp_c": 18, "summary": "Clear"}}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "The weather in Bengaluru is clear, 18°C."}], "finish_reason": "stop"}]`,
},
{
// langsmith capture, ChatOpenAI span answering with a tool call: the generation text is empty and
// the finish reason sits in generation_info
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/outputs/generation.py#L28
name: "GenerationWithToolCalls_ToolCallPart_FinishFromGenerationInfo",
output: `{"generations":[[{"text":"","generation_info":{"finish_reason":"tool_calls"},"type":"ChatGeneration","message":{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"","response_metadata":{},"type":"ai","tool_calls":[{"name":"get_weather","args":{"city":"Bengaluru"},"id":"call_1","type":"tool_call"}]}}}]]}`,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]`,
},
{
// langsmith capture, ChatOpenAI span of a failed call: the error body sits in response_metadata
name: "GenerationWithErrorBody_EmptyMessage_FinishError",
output: `{"generations":[[{"text":"","generation_info":null,"type":"ChatGeneration","message":{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"","response_metadata":{"body":{"error":{"message":"Incorrect API key provided","type":"invalid_request_error","code":"invalid_api_key"},"status":401},"headers":{}},"type":"ai"}}}]]}`,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [], "finish_reason": "error"}]`,
},
{
// a ChatMessage carries its role in kwargs; an AIMessage from langchain-anthropic keeps the tool_use
// block in its content beside tool_calls
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/messages/chat.py#L18
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/partners/anthropic/langchain_anthropic/chat_models.py#L2571
name: "ChatMessageRole_ToolUseBlockBesideToolCalls_OneToolCallPart",
input: `{"messages":[[{"lc":1,"type":"constructor","id":["langchain","schema","messages","ChatMessage"],"kwargs":{"role":"developer","content":[{"type":"text","text":"Be brief."}]}},{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":[{"type":"text","text":"Calling."},{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"Paris"}}],"tool_calls":[{"name":"get_weather","args":{"city":"Paris"},"id":"toolu_1"}]}}]]}`,
wantInput: `[
{"role": "developer", "parts": [{"type": "text", "content": "Be brief."}]},
{"role": "assistant", "parts": [{"type": "text", "content": "Calling."}, {"type": "tool_call", "name": "get_weather", "id": "toolu_1", "arguments": {"city": "Paris"}}]}
]`,
wantOutput: `[]`,
},
{
// the finish reason under the key each partner writes: anthropic stop_reason, ollama done_reason
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/partners/anthropic/langchain_anthropic/chat_models.py#L2510
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/partners/ollama/langchain_ollama/chat_models.py#L24
name: "PartnerFinishReasonKeys_StopReasonAndDoneReason",
output: `{"generations":[[{"text":"Hi.","message":{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"Hi.","response_metadata":{"stop_reason":"end_turn","model":"claude-sonnet-4-5"}}}}],[{"text":"Hel","message":{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"Hel","response_metadata":{"model":"llama3","done_reason":"length"}}}}]]}`,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hi."}], "finish_reason": "stop"}, {"role": "assistant", "parts": [{"type": "text", "content": "Hel"}], "finish_reason": "length"}]`,
},
{
// langchain_core's standard content blocks, as partners write them under output_version v1
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/messages/content.py#L456
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/messages/content.py#L498
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/messages/content.py#L721
name: "StandardContentBlocks_ReasoningAndMediaParts",
output: `{"generations":[[{"text":"It is a duck.","message":{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":[{"type":"reasoning","reasoning":"Looks like a bird."},{"type":"text","text":"It is a duck."},{"type":"image","base64":"iVBORw0KGgo=","mime_type":"image/png"},{"type":"file","url":"https://example.com/a.pdf","mime_type":"application/pdf"}],"response_metadata":{"finish_reason":"stop","output_version":"v1"}}}}]]}`,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "reasoning", "content": "Looks like a bird."},
{"type": "text", "content": "It is a duck."},
{"type": "blob", "modality": "image", "mime_type": "image/png", "content": "iVBORw0KGgo="},
{"type": "uri", "modality": "document", "uri": "https://example.com/a.pdf"}
], "finish_reason": "stop"}]`,
},
})
}

View File

@@ -167,8 +167,7 @@ func TestFormat_OpenAIChat(t *testing.T) {
]`,
},
{
// ChatCompletionRequestMessageContentPartFile {file: {file_id}}, as OpenInference records the request in input.value:
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L42650
// openinference capture, input.value of the ChatCompletion span
name: "FileByID_FilePart",
input: `{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": [{"type": "text", "text": "Summarise this document in one sentence."}, {"type": "file", "file": {"file_id": "file-6F2ksmvXxt4VdoqmHRw6kL"}}]}], "max_tokens": 60}`,
output: `{"id":"chatcmpl-DktFileCapture0001","object":"chat.completion","model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"finish_reason":"stop","logprobs":null,"message":{"role":"assistant","content":"The document is a short PDF about dragons.","refusal":null,"annotations":[]}}]}`,
@@ -190,8 +189,7 @@ func TestFormat_OpenAIChat(t *testing.T) {
]`,
},
{
// ChatCompletionRequestMessageContentPartFile {file: {filename, file_data}}, file_data a data URL:
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L42650
// openinference capture, input.value of the ChatCompletion span
name: "FileInline_BlobPart",
input: `{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": [{"type": "text", "text": "Summarise this document in one sentence."}, {"type": "file", "file": {"filename": "draconomicon.pdf", "file_data": "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbXS9Db3VudCAwPj5lbmRvYmoKdHJhaWxlcjw8L1Jvb3QgMSAwIFI+PgolJUVPRgo="}}]}], "max_tokens": 60}`,
wantInput: `[

View File

@@ -1,136 +0,0 @@
package aiobservabilitytypes
import "strings"
// Format: OpenAI Responses, a request {input, instructions} or a response {output, status} of items:
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L47597
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L69691
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L53834
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L57596
// Written by: gateways recording raw Responses bodies, and the raw calls OpenInference records for the
// Responses API and the OpenAI Agents SDK when its flattened messages are absent.
// Handled: message items with input_text, output_text, refusal, input_image and input_file content;
// function_call and custom_tool_call with their outputs; reasoning summaries; compaction; every other
// *_call item as a server tool call and *_output item as its response; the status and incomplete_details
// of a response.
// Dropped: reasoning encrypted_content, item ids. Not yet: input_audio content.
// isResponsesRequest tells a Responses request from an embeddings one, which also carries input and
// model: items are objects, and only Responses takes instructions or tools.
func isResponsesRequest(m map[string]any) bool {
if m["input"] == nil {
return false
}
if m["instructions"] != nil || m["tools"] != nil || m["previous_response_id"] != nil {
return true
}
items, ok := m["input"].([]any)
if !ok {
return false
}
_, isItem := firstObject(items)
return isItem
}
func isResponsesResponse(m map[string]any) bool {
if _, ok := m["output"].([]any); !ok {
return false
}
return stringOf(m["object"]) == "response" || m["status"] != nil
}
func (f *formatting) responsesRequest(m map[string]any) OutputMessages {
var msgs OutputMessages
if parts := systemParts(m["instructions"]); len(parts) > 0 {
msgs = append(msgs, OutputMessage{Role: RoleSystem, Parts: parts})
}
switch input := m["input"].(type) {
case string:
msgs = append(msgs, OutputMessage{Role: RoleUser, Parts: Parts{part(textPart(input))}})
case []any:
msgs = append(msgs, foldAssistant(f.responsesItems(input))...)
}
return msgs
}
// responsesResponse folds the output items into one message: the API returns a single choice. The
// finish reason is the incomplete_details reason, else the status:
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L69763
func (f *formatting) responsesResponse(m map[string]any) OutputMessages {
output, _ := m["output"].([]any)
msgs := foldAssistant(f.responsesItems(output))
if len(msgs) == 0 {
return msgs
}
last := &msgs[len(msgs)-1]
if details, ok := m["incomplete_details"].(map[string]any); ok {
last.FinishReason = finishReason(stringOf(details["reason"]))
} else if status := stringOf(m["status"]); status != "" {
last.FinishReason = finishReason(status)
}
return msgs
}
// responsesItems converts items one message each; the caller folds assistant runs.
func (f *formatting) responsesItems(items []any) OutputMessages {
msgs := make(OutputMessages, 0, len(items))
for _, item := range items {
m, ok := item.(map[string]any)
if !ok {
if s, ok := item.(string); ok {
msgs = append(msgs, OutputMessage{Role: RoleUser, Parts: Parts{part(textPart(s))}})
continue
}
msgs = append(msgs, genericMessage(item))
continue
}
typ := stringOf(m["type"])
switch {
case typ == "" || typ == "message":
msgs = append(msgs, f.openAIChatMessage(m))
case typ == "function_call" || typ == "custom_tool_call":
args := m["arguments"]
if args == nil {
args = m["input"]
}
call := ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(m["name"]), ID: optionalString(m["call_id"]), Arguments: decode(args)}
msgs = append(msgs, OutputMessage{Role: RoleAssistant, Parts: Parts{part(call)}})
case typ == "function_call_output" || typ == "custom_tool_call_output":
result := ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: optionalString(m["call_id"]), Response: toolResponse(m["output"])}
msgs = append(msgs, OutputMessage{Role: RoleTool, Parts: Parts{part(result)}})
case typ == "reasoning":
if p, ok := reasoningItem(m); ok {
msgs = append(msgs, OutputMessage{Role: RoleAssistant, Parts: Parts{p}})
}
case typ == "compaction":
msgs = append(msgs, OutputMessage{Role: RoleAssistant, Parts: Parts{part(CompactionPart{Type: PartTypeCompaction, ID: optionalString(m["id"]), Content: optionalString(m["content"])})}})
case strings.HasSuffix(typ, "_call"):
call := ServerToolCallPart{Type: PartTypeServerToolCall, Name: strings.TrimSuffix(typ, "_call"), ID: optionalString(m["call_id"]), ServerToolCall: GenericServerToolCall(m)}
msgs = append(msgs, OutputMessage{Role: RoleAssistant, Parts: Parts{part(call)}})
case strings.HasSuffix(typ, "_output") || typ == "mcp_approval_response":
result := ServerToolCallResponsePart{Type: PartTypeServerToolCallResponse, ID: optionalString(m["call_id"]), ServerToolCallResponse: GenericServerToolCallResponse(m)}
msgs = append(msgs, OutputMessage{Role: RoleTool, Parts: Parts{part(result)}})
default:
msgs = append(msgs, OutputMessage{Role: RoleAssistant, Parts: Parts{part(GenericPart(m))}})
}
}
return msgs
}
// reasoningItem joins the summary texts. An item with neither a summary nor encrypted content
// carries nothing to show:
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L69620
func reasoningItem(m map[string]any) (Part, bool) {
var texts []string
if summary, ok := m["summary"].([]any); ok {
for _, item := range summary {
if s, ok := item.(map[string]any); ok && stringOf(s["text"]) != "" {
texts = append(texts, stringOf(s["text"]))
}
}
}
if len(texts) == 0 && stringOf(m["encrypted_content"]) == "" {
return Part{}, false
}
return part(ReasoningPart{Type: PartTypeReasoning, Content: strings.Join(texts, "\n")}), true
}

View File

@@ -1,85 +0,0 @@
package aiobservabilitytypes
import "testing"
// Cases follow the OpenAI Responses schemas; the first mirrors the raw bodies in the SigNoz/scripts
// openinference-openai-agents capture, which OpenInference's flattened messages otherwise cover.
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L47597
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L69691
func TestFormat_OpenAIResponses(t *testing.T) {
runFormatCases(t, []formatCase{
{
// openinference-openai-agents capture, Response span: instructions outside the history, a reasoning
// item without a summary, then the message
name: "InstructionsAndStringInput_ReasoningWithoutSummaryDropped",
input: `{"include": [], "input": [{"content": "A bat and a ball cost $1.10. How much is the ball?", "role": "user"}], "instructions": "You solve problems step by step and give a final answer.", "model": "o3-mini", "tools": []}`,
output: `{"id":"resp_1","object":"response","status":"completed","model":"o3-mini-2025-01-31","output":[{"id":"rs_1","summary":[],"type":"reasoning"},{"id":"msg_1","content":[{"annotations":[],"text":"Final Answer: The ball costs $0.05.","type":"output_text","logprobs":[]}],"role":"assistant","status":"completed","type":"message"}],"usage":{"input_tokens":49,"output_tokens":431}}`,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You solve problems step by step and give a final answer."}]},
{"role": "user", "parts": [{"type": "text", "content": "A bat and a ball cost $1.10. How much is the ball?"}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Final Answer: The ball costs $0.05."}], "finish_reason": "stop"}]`,
},
{
// an InputMessage with every InputContent type, then function and custom tool calls with their
// outputs, a compaction item and a computer_call_output
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L53827
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L54142
name: "InputItems_EveryContentType_FunctionCallRound",
input: `{"model":"gpt-4o-mini","input":[{"role":"user","content":[{"type":"input_text","text":"What is in this image and these files?"},{"type":"input_image","image_url":"https://example.com/duck.jpg","detail":"auto"},{"type":"input_image","file_id":"file-img","detail":"low"},{"type":"input_file","file_id":"file-abc"},{"type":"input_file","filename":"a.pdf","file_data":"data:application/pdf;base64,JVBERi0="},{"type":"input_file","file_url":"https://example.com/a.pdf"}]},{"type":"function_call","call_id":"call_1","name":"get_weather","arguments":"{\"city\":\"Paris\"}"},{"type":"function_call_output","call_id":"call_1","output":"{\"temp_c\":18}"},{"type":"custom_tool_call","call_id":"c1","name":"shell","input":"ls"},{"type":"custom_tool_call_output","call_id":"c1","output":"a.txt"},{"type":"compaction","id":"cmp_1","encrypted_content":"x"},{"type":"computer_call_output","call_id":"cc_1","output":{"type":"computer_screenshot","image_url":"data:image/png;base64,iVBORw0KGgo="}}]}`,
wantInput: `[
{"role": "user", "parts": [
{"type": "text", "content": "What is in this image and these files?"},
{"type": "uri", "modality": "image", "uri": "https://example.com/duck.jpg"},
{"type": "file", "modality": "image", "file_id": "file-img"},
{"type": "file", "modality": "document", "file_id": "file-abc", "mime_type": "application/pdf"},
{"type": "blob", "modality": "document", "mime_type": "application/pdf", "content": "JVBERi0="},
{"type": "uri", "modality": "document", "uri": "https://example.com/a.pdf"}
]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Paris"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_1", "response": {"temp_c": 18}}]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "shell", "id": "c1", "arguments": "ls"}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "c1", "response": "a.txt"}]},
{"role": "assistant", "parts": [{"type": "compaction", "id": "cmp_1"}]},
{"role": "tool", "parts": [{"type": "server_tool_call_response", "id": "cc_1", "server_tool_call_response": {"type": "computer_call_output", "call_id": "cc_1", "output": {"type": "computer_screenshot", "image_url": "data:image/png;base64,iVBORw0KGgo="}}}]}
]`,
wantOutput: `[]`,
},
{
// output items folded into the single choice: a ReasoningItem summary, a WebSearchToolCall, the
// OutputMessage with output_text and refusal, then a FunctionToolCall
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L57596
name: "OutputItems_FoldedIntoOneMessage_StatusCompleted",
output: `{"id":"resp_2","object":"response","status":"completed","output":[{"id":"rs_1","type":"reasoning","summary":[{"type":"summary_text","text":"Need the weather first."}],"encrypted_content":"gAAA"},{"id":"ws_1","type":"web_search_call","status":"completed","action":{"type":"search","query":"weather Paris"}},{"id":"msg_1","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Checking.","annotations":[]},{"type":"refusal","refusal":"I cannot do that."}]},{"type":"function_call","call_id":"call_1","name":"get_weather","arguments":"{\"city\":\"Paris\"}","id":"fc_1","status":"completed"}]}`,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "reasoning", "content": "Need the weather first."},
{"type": "server_tool_call", "name": "web_search", "server_tool_call": {"id": "ws_1", "type": "web_search_call", "status": "completed", "action": {"type": "search", "query": "weather Paris"}}},
{"type": "text", "content": "Checking."},
{"type": "text", "content": "I cannot do that."},
{"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Paris"}}
], "finish_reason": "stop"}]`,
},
{
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L69763
name: "IncompleteResponse_FinishFromIncompleteDetails",
output: `{"object":"response","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Partial"}]}]}`,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Partial"}], "finish_reason": "length"}]`,
},
{
name: "StringInput_UserMessage",
input: `{"model":"gpt-4o-mini","input":"Say hi","instructions":"Be brief."}`,
wantInput: `[{"role": "system", "parts": [{"type": "text", "content": "Be brief."}]}, {"role": "user", "parts": [{"type": "text", "content": "Say hi"}]}]`,
wantOutput: `[]`,
},
{
// openinference capture, CreateEmbeddings span: an embeddings request also has input and model
name: "EmbeddingsRequest_NotResponses_KeptAsGeneric",
input: `{"input": ["observability is the new monitoring"], "model": "text-embedding-3-small", "encoding_format": "base64"}`,
warnings: []string{"message format not recognised, kept as generic"},
wantInput: `[{"role": "", "parts": [{"type": "generic", "content": "{\"encoding_format\":\"base64\",\"input\":[\"observability is the new monitoring\"],\"model\":\"text-embedding-3-small\"}"}]}]`,
wantOutput: `[]`,
},
})
}

View File

@@ -1,137 +0,0 @@
package aiobservabilitytypes
import "github.com/SigNoz/signoz/pkg/valuer"
// Format: the flattened llm.input_messages.N.message.* and llm.output_messages.N.message.* keys, the
// provider-neutral copy of the conversation the OpenInference instrumentations write on LLM spans. A side
// without them falls back to the raw provider payload the mapper moved onto gen_ai.*.messages; pipecat
// streams the reply into output.value alone.
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/semantic_conventions.md#L77
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/tool_calling.md#L37
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/multimodal_attributes.md#L35
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/llm_spans.md#L181
// Handled: message.content, the ordered message.contents items (text, image, audio, video, reasoning),
// message.tool_calls, the legacy message.function_call_*, tool results, llm.finish_reason. A tool_use
// content item repeats a call from message.tool_calls and is skipped.
// Dropped: reasoning id, signature, data and encrypted_content; audio transcripts.
const (
openInferenceInputMessages = "llm.input_messages"
openInferenceOutputMessages = "llm.output_messages"
openInferenceFinishReason = "llm.finish_reason"
)
func (f *formatting) openInference() bool {
input := indexed(f.attributes, openInferenceInputMessages)
output := indexed(f.attributes, openInferenceOutputMessages)
if len(input) == 0 && len(output) == 0 {
return false
}
inputLabel, outputLabel := FormatterOpenInference, FormatterOpenInference
if len(input) > 0 {
f.out.Input = inputMessages(openInferenceMessages(input))
} else if raw := attribute(f.attributes, GenAIInputMessages); raw != nil {
var msgs OutputMessages
msgs, inputLabel = f.side(decode(raw), RoleUser)
f.out.Input = inputMessages(msgs)
}
if len(output) > 0 {
f.out.Output = openInferenceMessages(output)
if fr := finishReason(stringOf(attribute(f.attributes, openInferenceFinishReason))); fr != nil {
for i := range f.out.Output {
f.out.Output[i].FinishReason = fr
}
}
} else if raw := attribute(f.attributes, GenAIOutputMessages); raw != nil {
f.out.Output, outputLabel = f.side(decode(raw), RoleAssistant)
}
f.warnMixedFormats(inputLabel, outputLabel)
return true
}
// openInferenceMessages converts the message buckets and folds runs of assistant messages: the Responses
// path writes one message per output item, an empty one for a reasoning item without a summary.
func openInferenceMessages(buckets []map[string]any) OutputMessages {
msgs := make(OutputMessages, 0, len(buckets))
for _, bucket := range buckets {
message, _ := bucket["message"].(map[string]any)
msgs = append(msgs, openInferenceMessage(message))
}
return foldAssistant(msgs)
}
// openInferenceMessage converts one message.* object. A message without a role is a tool response when
// it has a tool_call_id, an assistant turn when it has tool calls, else a user turn, as the
// collector-contrib normalizer decides it:
// https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/fc79b95297776dec6c47490cd6f6026b942bf6fb/processor/genainormalizerprocessor/internal/openinference/messages.go#L473
func openInferenceMessage(m map[string]any) OutputMessage {
msg := OutputMessage{Role: role(stringOf(m["role"])), Parts: Parts{}, Name: optionalString(m["name"])}
id := stringOf(m["tool_call_id"])
calls := indexed(m, "tool_calls")
if msg.Role.IsZero() {
switch {
case id != "":
msg.Role = RoleTool
case len(calls) > 0:
msg.Role = RoleAssistant
default:
msg.Role = RoleUser
}
}
if msg.Role == RoleTool && id != "" {
msg.Parts = Parts{part(ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: &id, Response: decode(m["content"])})}
return msg
}
if content := stringOf(m["content"]); content != "" {
msg.Parts = append(msg.Parts, part(textPart(content)))
}
msg.Parts = append(msg.Parts, openInferenceContents(m)...)
for _, call := range calls {
if c, ok := call["tool_call"].(map[string]any); ok {
msg.Parts = append(msg.Parts, openInferenceToolCall(c))
}
}
if name := stringOf(m["function_call_name"]); name != "" {
msg.Parts = append(msg.Parts, part(ToolCallRequestPart{Type: PartTypeToolCall, Name: name, Arguments: decode(m["function_call_arguments_json"])}))
}
return msg
}
// openInferenceContents converts the ordered message.contents items. A text item may come without
// message_content.type, as the collector-contrib normalizer reads it:
// https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/fc79b95297776dec6c47490cd6f6026b942bf6fb/processor/genainormalizerprocessor/internal/openinference/messages.go#L285
func openInferenceContents(m map[string]any) Parts {
items := indexed(m, "contents")
parts := make(Parts, 0, len(items))
for _, item := range items {
content, _ := item["message_content"].(map[string]any)
typ := stringOf(content["type"])
if typ == "" && content["text"] != nil {
typ = "text"
}
switch typ {
case "text":
parts = append(parts, part(textPart(stringOf(content["text"]))))
case "reasoning":
parts = append(parts, part(ReasoningPart{Type: PartTypeReasoning, Content: stringOf(content["text"])}))
case "image", "audio", "video":
if url := stringOf(attribute(content, typ+"."+typ+".url")); url != "" {
parts = append(parts, part(mediaPart(url, Modality{valuer.NewString(typ)})))
}
case "tool_use":
default:
parts = append(parts, part(GenericPart(content)))
}
}
return parts
}
// openInferenceToolCall reads a tool_call.* object, {id, function: {name, arguments}}.
func openInferenceToolCall(call map[string]any) Part {
fn, _ := call["function"].(map[string]any)
p := ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(fn["name"]), ID: optionalString(call["id"])}
if args, ok := fn["arguments"]; ok {
p.Arguments = decode(args)
}
return part(p)
}

View File

@@ -1,324 +0,0 @@
package aiobservabilitytypes
import "testing"
// Captured cases are the llm.input_messages.N.* and llm.output_messages.N.* keys as the collector
// stored them for the SigNoz/scripts static-telemetry-generator captures.
func TestFormat_OpenInference(t *testing.T) {
runFormatCases(t, []formatCase{
{
// openinference capture, ChatCompletion span: the flattened keys win over the raw request the
// mapper moved onto gen_ai.input.messages
name: "FlattenedMessages_RawRequestIgnored",
attributes: map[string]any{
"llm.input_messages.0.message.role": "system",
"llm.input_messages.0.message.content": "You are concise.",
"llm.input_messages.1.message.role": "user",
"llm.input_messages.1.message.content": "Say hello in five words.",
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.content": "Hello! How are you today?",
"llm.finish_reason": "stop",
GenAIInputMessages: `{"messages": [{"role": "system", "content": "You are concise."}, {"role": "user", "content": "Say hello in five words."}], "model": "gpt-4o-mini", "max_tokens": 50, "temperature": 0.2}`,
},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are concise."}]},
{"role": "user", "parts": [{"type": "text", "content": "Say hello in five words."}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello! How are you today?"}], "finish_reason": "stop"}]`,
},
{
// openinference capture, ChatCompletion span: message.tool_calls and llm.finish_reason
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/tool_calling.md#L37
name: "ToolCalls_ToolCallParts_FinishReasonFromLLMKey",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.content": "What is the weather in Bengaluru in celsius?",
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.tool_calls.0.tool_call.id": "call_MdYKTRrkshuBG9W5oBXYZaRc",
"llm.output_messages.0.message.tool_calls.0.tool_call.function.name": "get_current_weather",
"llm.output_messages.0.message.tool_calls.0.tool_call.function.arguments": `{"city":"Bengaluru","unit":"c"}`,
"llm.finish_reason": "tool_calls",
},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "What is the weather in Bengaluru in celsius?"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_MdYKTRrkshuBG9W5oBXYZaRc", "arguments": {"city": "Bengaluru", "unit": "c"}}], "finish_reason": "tool_call"}]`,
},
{
// openinference capture, the follow-up ChatCompletion span: tool role messages carry the result
// in message.content with their tool_call_id
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/llm_spans.md#L50
name: "ToolRoleMessages_ToolCallResponseParts",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.content": "What's the weather in Paris and in London? Use the tool for each.",
"llm.input_messages.1.message.role": "assistant",
"llm.input_messages.1.message.tool_calls.0.tool_call.id": "call_SW4hst7nzysgvWSAz4OdyK9D",
"llm.input_messages.1.message.tool_calls.0.tool_call.function.name": "get_current_weather",
"llm.input_messages.1.message.tool_calls.0.tool_call.function.arguments": `{"city": "Paris"}`,
"llm.input_messages.1.message.tool_calls.1.tool_call.id": "call_si8wiETYGpooMB7gMXSgMQpk",
"llm.input_messages.1.message.tool_calls.1.tool_call.function.name": "get_current_weather",
"llm.input_messages.1.message.tool_calls.1.tool_call.function.arguments": `{"city": "London"}`,
"llm.input_messages.2.message.role": "tool",
"llm.input_messages.2.message.tool_call_id": "call_SW4hst7nzysgvWSAz4OdyK9D",
"llm.input_messages.2.message.content": `{"city": "Paris", "temp_c": 18, "summary": "Clear"}`,
"llm.input_messages.3.message.role": "tool",
"llm.input_messages.3.message.tool_call_id": "call_si8wiETYGpooMB7gMXSgMQpk",
"llm.input_messages.3.message.content": `{"city": "London", "temp_c": 18, "summary": "Clear"}`,
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.content": "The current weather is as follows:\n\n- **Paris**: 18°C, Clear\n- **London**: 18°C, Clear",
"llm.finish_reason": "stop",
},
wantInput: `[
{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Paris and in London? Use the tool for each."}]},
{"role": "assistant", "parts": [
{"type": "tool_call", "name": "get_current_weather", "id": "call_SW4hst7nzysgvWSAz4OdyK9D", "arguments": {"city": "Paris"}},
{"type": "tool_call", "name": "get_current_weather", "id": "call_si8wiETYGpooMB7gMXSgMQpk", "arguments": {"city": "London"}}
]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_SW4hst7nzysgvWSAz4OdyK9D", "response": {"city": "Paris", "temp_c": 18, "summary": "Clear"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_si8wiETYGpooMB7gMXSgMQpk", "response": {"city": "London", "temp_c": 18, "summary": "Clear"}}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "The current weather is as follows:\n\n- **Paris**: 18°C, Clear\n- **London**: 18°C, Clear"}], "finish_reason": "stop"}]`,
},
{
// openinference capture, vision call; base64 trimmed. An image as a data URL:
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/multimodal_attributes.md#L47
name: "ContentsTextAndImage_TextAndBlobParts",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.contents.0.message_content.type": "text",
"llm.input_messages.0.message.contents.0.message_content.text": "What animal is in this image? One sentence.",
"llm.input_messages.0.message.contents.1.message_content.type": "image",
"llm.input_messages.0.message.contents.1.message_content.image.image.url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBD",
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.content": "The animal in the image is a duck.",
"llm.finish_reason": "stop",
},
wantInput: `[{"role": "user", "parts": [
{"type": "text", "content": "What animal is in this image? One sentence."},
{"type": "blob", "modality": "image", "mime_type": "image/jpeg", "content": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBD"}
]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "The animal in the image is a duck."}], "finish_reason": "stop"}]`,
},
{
// openinference-openai-agents capture, Response span: one output message per item, an empty one
// for a reasoning item without a summary
name: "ResponsesOutputItems_AssistantMessagesFolded_EmptyDropped",
attributes: map[string]any{
"llm.input_messages.0.message.role": "system",
"llm.input_messages.0.message.content": "You solve problems step by step and give a final answer.",
"llm.input_messages.1.message.role": "user",
"llm.input_messages.1.message.content": "A bat and a ball cost $1.10. How much is the ball?",
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.1.message.role": "assistant",
"llm.output_messages.1.message.contents.0.message_content.type": "text",
"llm.output_messages.1.message.contents.0.message_content.text": "Final Answer: The ball costs $0.05.",
"llm.output_messages.2.message.role": "assistant",
"llm.output_messages.2.message.tool_calls.0.tool_call.id": "call_1",
"llm.output_messages.2.message.tool_calls.0.tool_call.function.name": "done",
},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You solve problems step by step and give a final answer."}]},
{"role": "user", "parts": [{"type": "text", "content": "A bat and a ball cost $1.10. How much is the ball?"}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "text", "content": "Final Answer: The ball costs $0.05."},
{"type": "tool_call", "name": "done", "id": "call_1"}
]}]`,
},
{
// openinference capture, the ChatCompletion span of a failed call
name: "InputOnly_NoOutput",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.content": "this should fail",
},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "this should fail"}]}]`,
wantOutput: `[]`,
},
{
// the strands processor keeps the SDK's gen_ai.system_instructions on the span and inserts the same
// prompt as message 0
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-strands-agents/src/openinference/instrumentation/strands_agents/processor.py#L152
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-strands-agents/src/openinference/instrumentation/strands_agents/processor.py#L293
name: "StrandsSystemInstructionsRepeated_OneSystemMessage",
attributes: map[string]any{
"llm.input_messages.0.message.role": "system",
"llm.input_messages.0.message.content": "You are concise.",
"llm.input_messages.1.message.role": "user",
"llm.input_messages.1.message.content": "hi",
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.content": "Hello.",
GenAISystemInstructions: `[{"type": "text", "content": "You are concise."}]`,
},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are concise."}]},
{"role": "user", "parts": [{"type": "text", "content": "hi"}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello."}]}]`,
},
{
// pipecat writes the request as llm.input_messages and the streamed reply as output.value alone,
// which the mapper moves onto gen_ai.output.messages
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-pipecat/src/openinference/instrumentation/pipecat/_attributes.py#L430
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-pipecat/src/openinference/instrumentation/pipecat/_observer.py#L628
name: "OutputKeysAbsent_RawOutputFormatted_Warns",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.content": "hi",
GenAIOutputMessages: "Hello there.",
},
warnings: []string{"bare assistant text, role assumed", "output formatted as text, input as openinference"},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "hi"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello there."}]}]`,
},
{
// message.role left unset by some instrumentors: tool_call_id, tool_calls or neither decide it,
// as the collector-contrib normalizer does
// https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/fc79b95297776dec6c47490cd6f6026b942bf6fb/processor/genainormalizerprocessor/internal/openinference/messages.go#L473
name: "MissingRole_InferredFromToolFields",
attributes: map[string]any{
"llm.input_messages.0.message.content": "Weather in Oslo?",
"llm.input_messages.1.message.tool_calls.0.tool_call.id": "call_9",
"llm.input_messages.1.message.tool_calls.0.tool_call.function.name": "get_weather",
"llm.input_messages.1.message.tool_calls.0.tool_call.function.arguments": `{"city":"Oslo"}`,
"llm.input_messages.2.message.tool_call_id": "call_9",
"llm.input_messages.2.message.content": "4C, cloudy",
},
wantInput: `[
{"role": "user", "parts": [{"type": "text", "content": "Weather in Oslo?"}]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_9", "arguments": {"city": "Oslo"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_9", "response": "4C, cloudy"}]}
]`,
wantOutput: `[]`,
},
{
// message_content.type left unset on text items by some instrumentors, as the collector-contrib
// normalizer reads them
// https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/fc79b95297776dec6c47490cd6f6026b942bf6fb/processor/genainormalizerprocessor/internal/openinference/messages.go#L285
name: "UntypedContentWithText_TextPart",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.contents.0.message_content.text": "Summarise this.",
"llm.input_messages.0.message.contents.1.message_content.text": "Second block.",
},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "Summarise this."}, {"type": "text", "content": "Second block."}]}]`,
wantOutput: `[]`,
},
{
// an image given by URL
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/multimodal_attributes.md#L42
name: "ImageURL_UriPart",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.contents.0.message_content.type": "image",
"llm.input_messages.0.message.contents.0.message_content.image.image.url": "https://example.com/duck.jpg",
},
wantInput: `[{"role": "user", "parts": [{"type": "uri", "modality": "image", "uri": "https://example.com/duck.jpg"}]}]`,
wantOutput: `[]`,
},
{
// audio by URL with its transcript, or as the data URI built from a Chat Completions input_audio
// block; the transcript has no part to go to
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/multimodal_attributes.md#L55
name: "Audio_UriAndBlobParts_TranscriptDropped",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.contents.0.message_content.type": "audio",
"llm.input_messages.0.message.contents.0.message_content.audio.audio.url": "https://example.com/hello.mp3",
"llm.input_messages.0.message.contents.0.message_content.audio.audio.transcript": "Hello, how are you?",
"llm.input_messages.0.message.contents.1.message_content.type": "audio",
"llm.input_messages.0.message.contents.1.message_content.audio.audio.url": "data:audio/wav;base64,UklGRg==",
},
wantInput: `[{"role": "user", "parts": [
{"type": "uri", "modality": "audio", "uri": "https://example.com/hello.mp3"},
{"type": "blob", "modality": "audio", "mime_type": "audio/wav", "content": "UklGRg=="}
]}]`,
wantOutput: `[]`,
},
{
// reasoning items; id, signature, data and encrypted_content have no field on the reasoning part
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/llm_spans.md#L181
name: "Reasoning_ReasoningParts_ProviderKeysDropped",
attributes: map[string]any{
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.contents.0.message_content.type": "reasoning",
"llm.output_messages.0.message.contents.0.message_content.text": "The ball must cost less than a dollar.",
"llm.output_messages.0.message.contents.0.message_content.signature": "EuYBCkQYAiJA",
"llm.output_messages.0.message.contents.1.message_content.type": "reasoning",
"llm.output_messages.0.message.contents.1.message_content.data": "EmwKAhgBEgy3",
"llm.output_messages.0.message.contents.2.message_content.type": "reasoning",
"llm.output_messages.0.message.contents.2.message_content.id": "rs_abc123",
"llm.output_messages.0.message.contents.2.message_content.encrypted_content": "gAAAAA==",
"llm.output_messages.0.message.contents.3.message_content.type": "text",
"llm.output_messages.0.message.contents.3.message_content.text": "The ball costs 5 cents.",
},
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "reasoning", "content": "The ball must cost less than a dollar."},
{"type": "reasoning", "content": ""},
{"type": "reasoning", "content": ""},
{"type": "text", "content": "The ball costs 5 cents."}
]}]`,
},
{
// a tool call repeated as an ordered tool_use content item; message.tool_calls stays the one
// source. llm.finish_reason is Anthropic's stop_reason as the instrumentation passes it through
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/tool_calling.md#L63
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-anthropic/src/openinference/instrumentation/anthropic/_wrappers.py#L938
name: "ToolUseBesideToolCalls_OneToolCallPart",
attributes: map[string]any{
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.contents.0.message_content.type": "text",
"llm.output_messages.0.message.contents.0.message_content.text": "Let me check.",
"llm.output_messages.0.message.contents.1.message_content.type": "tool_use",
"llm.output_messages.0.message.contents.1.tool_call.id": "toolu_01",
"llm.output_messages.0.message.contents.1.tool_call.function.name": "get_weather",
"llm.output_messages.0.message.contents.1.tool_call.function.arguments": `{"city": "Paris"}`,
"llm.output_messages.0.message.tool_calls.0.tool_call.id": "toolu_01",
"llm.output_messages.0.message.tool_calls.0.tool_call.function.name": "get_weather",
"llm.output_messages.0.message.tool_calls.0.tool_call.function.arguments": `{"city": "Paris"}`,
"llm.finish_reason": "tool_use",
},
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "text", "content": "Let me check."},
{"type": "tool_call", "name": "get_weather", "id": "toolu_01", "arguments": {"city": "Paris"}}
], "finish_reason": "tool_call"}]`,
},
{
// a content type the spec does not name is kept as sent
name: "UnknownContentType_GenericPart",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.contents.0.message_content.type": "document",
"llm.input_messages.0.message.contents.0.message_content.document.document.id": "file_1",
},
wantInput: `[{"role": "user", "parts": [{"type": "document", "document": {"document": {"id": "file_1"}}}]}]`,
wantOutput: `[]`,
},
{
// the legacy function_call keys
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/semantic_conventions.md#L111
name: "LegacyFunctionCall_ToolCallPart",
attributes: map[string]any{
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.function_call_name": "multiply",
"llm.output_messages.0.message.function_call_arguments_json": `{"x": 2}`,
},
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "multiply", "arguments": {"x": 2}}]}]`,
},
{
// a tool result with message.name naming the function
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/llm_spans.md#L50
name: "ToolResultWithName_NameKept",
attributes: map[string]any{
"llm.input_messages.0.message.role": "tool",
"llm.input_messages.0.message.name": "multiply",
"llm.input_messages.0.message.tool_call_id": "call_62136355",
"llm.input_messages.0.message.content": "2001",
},
wantInput: `[{"role": "tool", "name": "multiply", "parts": [{"type": "tool_call_response", "id": "call_62136355", "response": "2001"}]}]`,
wantOutput: `[]`,
},
})
}

View File

@@ -1,47 +0,0 @@
package aiobservabilitytypes
import "strings"
// Format: the {completion, reasoning, tools, rawRequest} answer object OpenRouter's broadcast writes as
// gen_ai.completion on its LLM Generation spans, beside the OpenAI chat {messages} request it writes as
// gen_ai.prompt; the span mapper moves both onto gen_ai.*.messages. A tool call arrives as the tool name
// followed by its JSON arguments in completion, so it is split against the names in tools.
// https://openrouter.ai/docs/guides/features/broadcast/otel-collector
func isOpenRouterCompletion(m map[string]any) bool {
_, ok := m["completion"].(string)
return ok
}
func (f *formatting) openRouterCompletion(m map[string]any) OutputMessages {
msg := OutputMessage{Role: RoleAssistant, Parts: Parts{}}
if reasoning := stringOf(m["reasoning"]); reasoning != "" {
msg.Parts = append(msg.Parts, part(ReasoningPart{Type: PartTypeReasoning, Content: reasoning}))
}
completion := stringOf(m["completion"])
if call, ok := openRouterToolCall(completion, m["tools"]); ok {
msg.Parts = append(msg.Parts, call)
msg.FinishReason = finishReason("tool_calls")
} else if completion != "" {
msg.Parts = append(msg.Parts, part(textPart(completion)))
}
return OutputMessages{msg}
}
func openRouterToolCall(completion string, tools any) (Part, bool) {
list, _ := tools.([]any)
for _, item := range list {
tool, _ := item.(map[string]any)
fn, _ := tool["function"].(map[string]any)
name := stringOf(fn["name"])
if name == "" || !strings.HasPrefix(completion, name) {
continue
}
args, ok := decode(strings.TrimPrefix(completion, name)).(map[string]any)
if !ok {
continue
}
return part(ToolCallRequestPart{Type: PartTypeToolCall, Name: name, Arguments: args}), true
}
return Part{}, false
}

View File

@@ -1,35 +0,0 @@
package aiobservabilitytypes
import "testing"
// Captured cases are gen_ai.input.messages and gen_ai.output.messages as the collector stored them for
// the SigNoz/scripts openrouter-gateway capture, LLM Generation spans.
func TestFormat_OpenRouter(t *testing.T) {
runFormatCases(t, []formatCase{
{
// the completion is the tool name followed by the JSON arguments
name: "CompletionIsToolNameAndArguments_ToolCallPart",
input: `{"messages":[{"role":"user","content":"What's the weather in Bengaluru? Use the tool."}]}`,
output: `{"completion":"get_current_weather{\"city\":\"Bengaluru\"}","reasoning":null,"tools":[{"type":"function","function":{"name":"get_current_weather","description":"Get the current weather for a city.","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}],"rawRequest":{"model":"openai/gpt-4o-mini","max_tokens":80,"tool_choice":"auto"}}`,
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Bengaluru? Use the tool."}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]`,
},
{
name: "CompletionWithReasoning_ReasoningThenText",
input: `{"messages":[{"role":"system","content":"You are concise."},{"role":"user","content":"Say hello in five words."}]}`,
output: `{"completion":"Hello there, how are you?","reasoning":"A greeting of exactly five words.","tools":[],"rawRequest":{"model":"openai/gpt-4o-mini"}}`,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are concise."}]},
{"role": "user", "parts": [{"type": "text", "content": "Say hello in five words."}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "reasoning", "content": "A greeting of exactly five words."}, {"type": "text", "content": "Hello there, how are you?"}]}]`,
},
{
// a completion that only starts with a tool name stays text
name: "CompletionStartsWithToolNameButNoArguments_Text",
output: `{"completion":"get_current_weather is the tool to call.","reasoning":null,"tools":[{"type":"function","function":{"name":"get_current_weather"}}]}`,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "get_current_weather is the tool to call."}]}]`,
},
})
}

View File

@@ -6,20 +6,15 @@ import (
)
// Format: the OTel shape itself, [{role, parts}].
// Written by: litellm, langchain, openllmetry, portkey, traceloop LLM spans, pydantic-ai.
// Decoding is the genai types. Repairs: "text" used for "content" on text parts; OpenAI content blocks
// left inside parts; tool call arguments as a JSON string and "result" for "response", as pydantic-ai
// writes them:
// https://github.com/pydantic/pydantic-ai/blob/9ac853bb84ffed7ba0edccb026c5b4f3228c1de7/pydantic_ai_slim/pydantic_ai/messages.py#L3130
// https://github.com/pydantic/pydantic-ai/blob/9ac853bb84ffed7ba0edccb026c5b4f3228c1de7/pydantic_ai_slim/pydantic_ai/messages.py#L1724
// The choice finish_reason as the provider spells it, as litellm copies it:
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2822
// Not yet: "thinking" for "reasoning".
// Written by: litellm, langchain, openllmetry, portkey, traceloop LLM spans.
// Decoding is the genai types. Repairs: "text" used for "content" on text parts; OpenAI content
// blocks left inside parts.
// Not yet: "thinking" for "reasoning"; "result" or "output" for "response" on tool call responses.
// semconv decodes messages that already follow the schema through the genai types. A part of a
// type the schema does not name is a provider content block, which contentPart converts.
func (f *formatting) semconv(list []any) OutputMessages {
data, err := json.Marshal(semconvRepairs(list))
data, err := json.Marshal(semconvTextKey(list))
if err != nil {
f.warn("messages could not be encoded: %s", err)
return OutputMessages{genericMessage(list)}
@@ -33,9 +28,6 @@ func (f *formatting) semconv(list []any) OutputMessages {
if msgs[i].Parts == nil {
msgs[i].Parts = Parts{}
}
if msgs[i].FinishReason != nil {
msgs[i].FinishReason = finishReason(msgs[i].FinishReason.StringValue())
}
for j, p := range msgs[i].Parts {
if block, ok := p.Value.(GenericPart); ok {
msgs[i].Parts[j] = contentPart(map[string]any(block))
@@ -45,9 +37,9 @@ func (f *formatting) semconv(list []any) OutputMessages {
return msgs
}
// semconvRepairs copies the parts whose keys SDKs misspell into the schema's spelling. The input
// is left untouched, it is also the span's raw attribute.
func semconvRepairs(list []any) []any {
// semconvTextKey copies the text and reasoning parts some SDKs write with "text" in place of the
// schema's "content". The input is left untouched, it is also the span's raw attribute.
func semconvTextKey(list []any) []any {
out := make([]any, 0, len(list))
for _, item := range list {
m, ok := item.(map[string]any)
@@ -58,8 +50,13 @@ func semconvRepairs(list []any) []any {
}
fixedParts := make([]any, 0, len(parts))
for _, p := range parts {
if part, ok := p.(map[string]any); ok {
p = semconvRepairPart(part)
part, ok := p.(map[string]any)
typ := stringOf(part["type"])
if ok && (typ == "text" || typ == "reasoning") && part["content"] == nil && part["text"] != nil {
fixed := maps.Clone(part)
fixed["content"] = fixed["text"]
delete(fixed, "text")
p = fixed
}
fixedParts = append(fixedParts, p)
}
@@ -69,24 +66,3 @@ func semconvRepairs(list []any) []any {
}
return out
}
func semconvRepairPart(part map[string]any) map[string]any {
fixed := maps.Clone(part)
switch stringOf(part["type"]) {
case "text", "reasoning":
if part["content"] == nil && part["text"] != nil {
fixed["content"] = fixed["text"]
delete(fixed, "text")
}
case "tool_call":
if args, ok := part["arguments"].(string); ok {
fixed["arguments"] = decode(args)
}
case "tool_call_response":
if _, ok := part["response"]; !ok && part["result"] != nil {
fixed["response"] = part["result"]
delete(fixed, "result")
}
}
return fixed
}

View File

@@ -157,34 +157,3 @@ func TestFormat_Semconv(t *testing.T) {
},
})
}
func TestFormat_Semconv_Repairs(t *testing.T) {
runFormatCases(t, []formatCase{
{
// pydantic-ai keeps string tool call arguments as sent, writes the tool result under result, and the
// instructions as a gen_ai.system_instructions text part:
// https://github.com/pydantic/pydantic-ai/blob/9ac853bb84ffed7ba0edccb026c5b4f3228c1de7/pydantic_ai_slim/pydantic_ai/messages.py#L3130
// https://github.com/pydantic/pydantic-ai/blob/9ac853bb84ffed7ba0edccb026c5b4f3228c1de7/pydantic_ai_slim/pydantic_ai/messages.py#L1724
// https://github.com/pydantic/pydantic-ai/blob/9ac853bb84ffed7ba0edccb026c5b4f3228c1de7/pydantic_ai_slim/pydantic_ai/models/instrumented.py#L292
name: "PydanticAI_ArgumentsDecoded_ResultBecomesResponse_InstructionsLead",
input: `[{"role":"user","parts":[{"type":"text","content":"Weather in Paris?"}]},{"role":"assistant","parts":[{"type":"tool_call","id":"call_stub1","name":"get_weather","arguments":"{\"city\": \"Paris\"}"}],"finish_reason":"tool_call"},{"role":"user","parts":[{"type":"tool_call_response","id":"call_stub1","name":"get_weather","result":{"city":"Paris","temp_c":18}}]}]`,
output: `[{"role":"assistant","parts":[{"type":"text","content":"Paris is clear at 18C."}],"finish_reason":"stop"}]`,
attributes: map[string]any{GenAISystemInstructions: `[{"type":"text","content":"Use the tool."}]`},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "Use the tool."}]},
{"role": "user", "parts": [{"type": "text", "content": "Weather in Paris?"}]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_stub1", "arguments": {"city": "Paris"}}]},
{"role": "user", "parts": [{"type": "tool_call_response", "id": "call_stub1", "response": {"city": "Paris", "temp_c": 18}}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Paris is clear at 18C."}], "finish_reason": "stop"}]`,
},
{
// litellm copies the choice finish_reason into the semconv message as OpenAI spells it:
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2822
name: "LiteLLMFinishReasonSpelling_Normalised",
output: `[{"role": "assistant", "parts": [], "finish_reason": "tool_calls"}]`,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [], "finish_reason": "tool_call"}]`,
},
})
}

View File

@@ -112,16 +112,6 @@ So, the ball costs 5 cents.`,
wantInput: `[{"role": "", "parts": [{"type": "generic", "content": "{\"city\":\"Paris\",\"temp_c\":21}"}]}]`,
wantOutput: `[]`,
},
{
// openinference capture, CreateEmbeddings span: the mapper moved the request and the vectors onto
// the message attributes and copied the span kind onto gen_ai.operation.name
name: "EmbeddingsOperation_EmptyLists",
input: `{"input": ["observability is the new monitoring"], "model": "text-embedding-3-small"}`,
output: `{"data":[{"embedding":[-0.0039,0.0203],"index":0}],"model":"text-embedding-3-small"}`,
attributes: map[string]any{GenAIOperationName: "EMBEDDING", GenAIRequestModel: "text-embedding-3-small"},
wantInput: `[]`,
wantOutput: `[]`,
},
{
name: "NoMessageAttributes_EmptyLists",
attributes: map[string]any{GenAIRequestModel: "gpt-4o", GenAIToolName: "get_weather"},
@@ -130,108 +120,3 @@ So, the ball costs 5 cents.`,
},
})
}
func TestIndexed(t *testing.T) {
testCases := []struct {
name string
doc map[string]any
want []map[string]any
}{
{
name: "NumericKeys_SortedNumerically",
doc: document(map[string]any{"llm.input_messages.10.message.role": "user", "llm.input_messages.2.message.role": "system", "llm.input_messages.x.message.role": "skipped"}),
want: []map[string]any{{"message": map[string]any{"role": "system"}}, {"message": map[string]any{"role": "user"}}},
},
{
name: "StructuredList_KeptInOrder",
doc: map[string]any{"llm": map[string]any{"input_messages": []any{map[string]any{"message": "b"}, map[string]any{"message": "a"}, "not an object"}}},
want: []map[string]any{{"message": "b"}, {"message": "a"}},
},
{name: "Absent_Empty", doc: map[string]any{}, want: []map[string]any{}},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, indexed(testCase.doc, openInferenceInputMessages))
})
}
}
func TestFormat_SystemInstructions(t *testing.T) {
input := `[{"role":"user","parts":[{"type":"text","content":"hi"}]}]`
runFormatCases(t, []formatCase{
{
// the langchain instrumentation moves system messages out of the history into gen_ai.system_instructions,
// a JSON list of parts:
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/span_utils.py#L304
name: "ListOfParts_LeadsInput",
input: input,
attributes: map[string]any{GenAISystemInstructions: `[{"type": "text", "content": "You are concise."}]`},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are concise."}]},
{"role": "user", "parts": [{"type": "text", "content": "hi"}]}
]`,
wantOutput: `[]`,
},
{
// the OpenInference strands processor keeps gen_ai.system_instructions on the span and inserts the
// same prompt as message 0
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-strands-agents/src/openinference/instrumentation/strands_agents/processor.py#L152
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-strands-agents/src/openinference/instrumentation/strands_agents/processor.py#L293
name: "SameTextLeadsInput_Skipped",
input: `[{"role":"system","parts":[{"type":"text","content":"You are concise."}]},{"role":"user","parts":[{"type":"text","content":"hi"}]}]`,
attributes: map[string]any{GenAISystemInstructions: `[{"type": "text", "content": "You are concise."}]`},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are concise."}]},
{"role": "user", "parts": [{"type": "text", "content": "hi"}]}
]`,
wantOutput: `[]`,
},
{
name: "BareString_TextPart",
input: input,
attributes: map[string]any{GenAISystemInstructions: "be brief"},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "be brief"}]},
{"role": "user", "parts": [{"type": "text", "content": "hi"}]}
]`,
wantOutput: `[]`,
},
{
// a create_agent span carries instructions alone
name: "NoMessages_Nothing",
attributes: map[string]any{GenAISystemInstructions: "be brief"},
wantInput: `[]`,
wantOutput: `[]`,
},
})
}
func TestFormat_FinishReasons(t *testing.T) {
output := `[{"role":"assistant","parts":[{"type":"text","content":"a"}],"finish_reason":"stop"},{"role":"assistant","parts":[{"type":"text","content":"b"}]}]`
runFormatCases(t, []formatCase{
{
name: "List_OverridesMessages",
output: output,
attributes: map[string]any{GenAIResponseFinishReasons: []any{"tool_calls", "length"}},
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "a"}], "finish_reason": "tool_call"}, {"role": "assistant", "parts": [{"type": "text", "content": "b"}], "finish_reason": "length"}]`,
},
{
name: "LengthMismatch_LeavesMessages_Warns",
output: output,
attributes: map[string]any{GenAIResponseFinishReasons: []any{"stop"}},
warnings: []string{"gen_ai.response.finish_reasons has 1 entries for 2 output messages"},
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "a"}], "finish_reason": "stop"}, {"role": "assistant", "parts": [{"type": "text", "content": "b"}]}]`,
},
{
// llm.finish_reason copied by the mapper as a bare string, in the Vercel AI SDK spelling:
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-finish-reason.ts
name: "BareString_SingleChoice",
output: `{"choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"hi"}}]}`,
attributes: map[string]any{GenAIResponseFinishReasons: "tool-calls"},
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "hi"}], "finish_reason": "tool_call"}]`,
},
})
}

View File

@@ -1,15 +1,12 @@
package aiobservabilitytypes
// Format: gen_ai.tool.call.arguments and gen_ai.tool.call.result on an execute_tool span, each an
// object or its JSON string:
// https://github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/model/gen-ai/registry.yaml#L545
// object or its JSON string (open-telemetry/semantic-conventions-genai@06ec68e, model/gen-ai/registry.yaml).
// Written by: opentelemetry-instrumentation-langchain, traceloop @tool, OpenInference TOOL spans and
// Vercel ai.toolCall.* through the span mapper.
// Envelopes unwrapped: LangChain's on_tool_start and on_tool_end payloads, {input_str, tags, metadata,
// inputs, kwargs} and {output, kwargs}:
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py#L837
// The traceloop decorator's {args, kwargs}:
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/traceloop-sdk/traceloop/sdk/decorators/base.py#L169
// Envelopes unwrapped: LangChain's callback payloads, {input_str, tags, metadata, inputs, kwargs} and
// {output, kwargs} (openllmetry callback_handler.py on_tool_start/on_tool_end), and the traceloop
// decorator's {args, kwargs} (traceloop-sdk decorators/base.py _handle_span_input).
// tool renders the call as an assistant tool_call and the result as a tool tool_call_response, both
// carrying gen_ai.tool.call.id so the pair can be matched.

View File

@@ -7,9 +7,8 @@ import "testing"
func TestFormat_Tool(t *testing.T) {
runFormatCases(t, []formatCase{
{
// gen_ai.tool.call.arguments and gen_ai.tool.call.result written by the langchain instrumentation itself,
// wrapped in its on_tool_start and on_tool_end payloads:
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py#L837
// langchain capture, execute_tool get_weather span; opentelemetry-instrumentation-langchain
// callback_handler.py on_tool_start/on_tool_end envelopes
name: "LangChainEnvelopes_InputsAndOutputUnwrapped",
attributes: map[string]any{
GenAIToolName: "get_weather",
@@ -20,9 +19,8 @@ func TestFormat_Tool(t *testing.T) {
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "response": {"city": "Bengaluru", "temp_c": 18, "summary": "Clear"}}]}]`,
},
{
// traceloop.entity.input and traceloop.entity.output from the @tool decorator, copied by the mapper;
// the tool returned a JSON string, which json.dumps encoded again:
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/traceloop-sdk/traceloop/sdk/decorators/base.py#L169
// openllmetry capture, get_weather.tool span; traceloop-sdk decorators/base.py
// _handle_span_input {args, kwargs} and the result encoded as a JSON string twice
name: "TraceloopDecorator_KwargsUnwrapped_ResultDecodedTwice",
attributes: map[string]any{
GenAIToolName: "get_weather",
@@ -33,8 +31,8 @@ func TestFormat_Tool(t *testing.T) {
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "response": {"city": "Bengaluru", "temp_c": 28, "summary": "Partly cloudy"}}]}]`,
},
{
// traceloop.entity.output of a @tool returning None, json.dumps(None), copied by the mapper:
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/traceloop-sdk/traceloop/sdk/decorators/base.py#L185
// a decorated function returning None is json.dumps(None); the openllmetry capture shows it on
// workflow spans, with this positional-args input
name: "TraceloopDecorator_PositionalArgsOnly_ListKept",
attributes: map[string]any{
GenAIToolName: "lookup",
@@ -54,8 +52,8 @@ func TestFormat_Tool(t *testing.T) {
wantOutput: `[]`,
},
{
// input.value and output.value of an OpenInference TOOL span, copied by the mapper:
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/traces.md
// openinference-openai-agents capture, get_weather TOOL span: input.value and output.value
// copied by the mapper (Arize-ai/openinference@12f2cc03, spec/traces.md)
name: "OpenInferenceToolSpan_PlainJSONAndText",
attributes: map[string]any{
GenAIToolName: "get_weather",
@@ -66,8 +64,8 @@ func TestFormat_Tool(t *testing.T) {
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "response": "27C, partly cloudy, light breeze from the south."}]}]`,
},
{
// ai.toolCall.{id,name,args,result} of a Vercel ai.toolCall span, copied by the mapper:
// https://ai-sdk.dev/docs/ai-sdk-core/telemetry
// vercel capture, ai.toolCall span: ai.toolCall.{id,name,args,result} copied by the mapper
// (https://ai-sdk.dev/docs/ai-sdk-core/telemetry)
name: "VercelToolCall_IDOnBothParts",
attributes: map[string]any{
GenAIToolName: "get_current_weather",

View File

@@ -1,87 +0,0 @@
package aiobservabilitytypes
// Format: the flattened gen_ai.prompt.N.* and gen_ai.completion.N.* keys of openllmetry's instrumentations
// from before their semconv rewrite, role, content, tool_call_id, tool_calls.M.{id,name,arguments} and
// finish_reason, with list content as its JSON:
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/opentelemetry-semantic-conventions-ai/opentelemetry/semconv_ai/__init__.py#L159
// https://github.com/traceloop/openllmetry/blob/af487056a35625a2cff1c17ac83c2c0d90a02bca/packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/shared/chat_wrappers.py#L408
// https://github.com/traceloop/openllmetry/blob/af487056a35625a2cff1c17ac83c2c0d90a02bca/packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/shared/chat_wrappers.py#L460
// Written by: crewai through the openai instrumentation 0.45.6, mistralai 0.61.0 (https://github.com/traceloop/openllmetry/blob/4643b8826a6e6a4ce1968178d6ac9ca3ce607e2e/packages/opentelemetry-instrumentation-mistralai/opentelemetry/instrumentation/mistralai/__init__.py#L83),
// litellm beside its semconv value.
const (
traceloopPrompts = "gen_ai.prompt"
traceloopCompletions = "gen_ai.completion"
)
func (f *formatting) traceloop() bool {
prompts := indexed(f.attributes, traceloopPrompts)
completions := indexed(f.attributes, traceloopCompletions)
if len(prompts) == 0 && len(completions) == 0 {
return false
}
f.out.Input = inputMessages(f.traceloopMessages(prompts))
f.out.Output = f.traceloopMessages(completions)
return true
}
// completionToolCalls fills an output message left without parts from the gen_ai.completion.N keys
// litellm writes beside its semconv value, whose parts never carry the tool call:
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2365
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2770
func (f *formatting) completionToolCalls() {
for i := range f.out.Output {
if len(f.out.Output[i].Parts) == 0 {
f.out.Output[i].Parts = append(f.out.Output[i].Parts, traceloopToolCalls(indexedAt(f.attributes, traceloopCompletions, i))...)
}
}
}
func (f *formatting) traceloopMessages(buckets []map[string]any) OutputMessages {
out := make(OutputMessages, 0, len(buckets))
for i, m := range buckets {
msg := OutputMessage{Role: role(stringOf(m["role"])), Parts: Parts{}, Name: optionalString(m["name"]), FinishReason: finishReason(stringOf(m["finish_reason"]))}
content, hasContent := m["content"]
if id := stringOf(m["tool_call_id"]); msg.Role == RoleTool && id != "" {
msg.Parts = Parts{part(ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: &id, Response: decode(content)})}
out = append(out, msg)
continue
}
if hasContent {
msg.Parts = append(msg.Parts, traceloopContent(content)...)
}
msg.Parts = append(msg.Parts, traceloopToolCalls(m)...)
if len(msg.Parts) == 0 {
f.warn("message %d has no content", i)
}
out = append(out, msg)
}
return out
}
// traceloopContent reads a content value, a string or the JSON of an OpenAI content block list.
func traceloopContent(content any) Parts {
if blocks, ok := decode(content).([]any); ok {
parts := make(Parts, 0, len(blocks))
for _, block := range blocks {
parts = append(parts, contentPart(block))
}
return parts
}
if text := stringOf(content); text != "" {
return Parts{part(textPart(text))}
}
return nil
}
// traceloopToolCalls reads tool_calls.M.{id,name,arguments} and litellm's function_call object.
func traceloopToolCalls(m map[string]any) Parts {
var parts Parts
for _, call := range indexed(m, "tool_calls") {
parts = append(parts, part(ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(call["name"]), ID: optionalString(call["id"]), Arguments: decode(call["arguments"])}))
}
if call, ok := m["function_call"].(map[string]any); ok {
parts = append(parts, part(ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(call["name"]), Arguments: decode(call["arguments"])}))
}
return parts
}

View File

@@ -1,106 +0,0 @@
package aiobservabilitytypes
import "testing"
// Captured cases are the gen_ai.prompt.N.* and gen_ai.completion.N.* keys as the collector stored
// them for the SigNoz/scripts crewai, mistral and litellm captures.
func TestFormat_Traceloop(t *testing.T) {
runFormatCases(t, []formatCase{
{
// crewai capture, openai.chat span of a tool round, as the openai instrumentation 0.45.6 wrote
// prompts and completions before its semconv rewrite
// https://github.com/traceloop/openllmetry/blob/af487056a35625a2cff1c17ac83c2c0d90a02bca/packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/shared/chat_wrappers.py#L408
name: "ToolRound_ToolCallAndToolMessages",
attributes: map[string]any{
"gen_ai.prompt.0.role": "system",
"gen_ai.prompt.0.content": "You are Weather Reporter. Call get_weather and summarise the result.",
"gen_ai.prompt.1.role": "user",
"gen_ai.prompt.1.content": "Current Task: What's the weather in Bengaluru?",
"gen_ai.prompt.2.role": "assistant",
"gen_ai.prompt.2.tool_calls.0.id": "call_bbVgWPe8AMBol7JlAhDkA6R7",
"gen_ai.prompt.2.tool_calls.0.name": "get_weather",
"gen_ai.prompt.2.tool_calls.0.arguments": `{"city":"Bengaluru"}`,
"gen_ai.prompt.3.role": "tool",
"gen_ai.prompt.3.tool_call_id": "call_bbVgWPe8AMBol7JlAhDkA6R7",
"gen_ai.prompt.3.content": `{"city": "Bengaluru", "temp_c": 18, "summary": "Clear"}`,
"gen_ai.completion.0.role": "assistant",
"gen_ai.completion.0.finish_reason": "stop",
"gen_ai.completion.0.content": "The current weather in Bengaluru is clear with a temperature of 18°C.",
},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are Weather Reporter. Call get_weather and summarise the result."}]},
{"role": "user", "parts": [{"type": "text", "content": "Current Task: What's the weather in Bengaluru?"}]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_bbVgWPe8AMBol7JlAhDkA6R7", "arguments": {"city": "Bengaluru"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_bbVgWPe8AMBol7JlAhDkA6R7", "response": {"city": "Bengaluru", "temp_c": 18, "summary": "Clear"}}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "The current weather in Bengaluru is clear with a temperature of 18°C."}], "finish_reason": "stop"}]`,
},
{
// crewai capture, openai.chat span answering with two tool calls
// https://github.com/traceloop/openllmetry/blob/af487056a35625a2cff1c17ac83c2c0d90a02bca/packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/shared/chat_wrappers.py#L460
name: "CompletionToolCalls_ToolCallParts",
attributes: map[string]any{
"gen_ai.prompt.0.role": "user",
"gen_ai.prompt.0.content": "What's the weather in Paris and in London?",
"gen_ai.completion.0.role": "assistant",
"gen_ai.completion.0.finish_reason": "tool_calls",
"gen_ai.completion.0.tool_calls.0.id": "call_1",
"gen_ai.completion.0.tool_calls.0.name": "get_weather",
"gen_ai.completion.0.tool_calls.0.arguments": `{"city":"Paris"}`,
"gen_ai.completion.0.tool_calls.1.id": "call_2",
"gen_ai.completion.0.tool_calls.1.name": "get_weather",
"gen_ai.completion.0.tool_calls.1.arguments": `{"city":"London"}`,
},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Paris and in London?"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Paris"}},
{"type": "tool_call", "name": "get_weather", "id": "call_2", "arguments": {"city": "London"}}
], "finish_reason": "tool_call"}]`,
},
{
// mistral capture, mistralai.chat span: the instrumentation writes the role for every message but
// drops a content that is not a plain string
// https://github.com/traceloop/openllmetry/blob/4643b8826a6e6a4ce1968178d6ac9ca3ce607e2e/packages/opentelemetry-instrumentation-mistralai/opentelemetry/instrumentation/mistralai/__init__.py#L75
// https://github.com/traceloop/openllmetry/blob/4643b8826a6e6a4ce1968178d6ac9ca3ce607e2e/packages/opentelemetry-instrumentation-mistralai/opentelemetry/instrumentation/mistralai/__init__.py#L88
name: "RoleWithoutContent_EmptyMessageWarns",
attributes: map[string]any{
"gen_ai.prompt.0.role": "user",
"gen_ai.completion.0.role": "assistant",
"gen_ai.completion.0.finish_reason": "stop",
"gen_ai.completion.0.content": "Observability is the ability to understand a system's internal state by analyzing its external outputs.",
},
warnings: []string{"message 0 has no content"},
wantInput: `[{"role": "user", "parts": []}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Observability is the ability to understand a system's internal state by analyzing its external outputs."}], "finish_reason": "stop"}]`,
},
{
// a vision prompt: list content is written as its JSON, the OpenAI content block list
// https://github.com/traceloop/openllmetry/blob/af487056a35625a2cff1c17ac83c2c0d90a02bca/packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/shared/chat_wrappers.py#L431
name: "ContentBlockList_TextAndBlobParts",
attributes: map[string]any{
"gen_ai.prompt.0.role": "user",
"gen_ai.prompt.0.content": `[{"type":"text","text":"What animal is this?"},{"type":"image_url","image_url":{"url":"data:image/jpeg;base64,/9j/4AAQ"}}]`,
},
wantInput: `[{"role": "user", "parts": [
{"type": "text", "content": "What animal is this?"},
{"type": "blob", "modality": "image", "mime_type": "image/jpeg", "content": "/9j/4AAQ"}
]}]`,
wantOutput: `[]`,
},
{
// litellm capture, litellm_request span: the semconv output has no part for the tool call, the
// gen_ai.completion.0.function_call keys beside it still carry it
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2365
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2770
name: "SemconvEmptyParts_ToolCallFromCompletionKeys",
attributes: map[string]any{
GenAIInputMessages: `[{"role": "user", "parts": [{"type": "text", "content": "What is the weather in Bengaluru in celsius?"}]}]`,
GenAIOutputMessages: `[{"role": "assistant", "parts": [], "finish_reason": "tool_calls"}]`,
"gen_ai.completion.0.function_call.name": "get_current_weather",
"gen_ai.completion.0.function_call.arguments": `{"city":"Bengaluru"}`,
},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "What is the weather in Bengaluru in celsius?"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]`,
},
})
}

View File

@@ -1,187 +0,0 @@
package aiobservabilitytypes
import (
"encoding/base64"
"strings"
)
// Format: the AI SDK's legacy telemetry attributes. ai.prompt.messages on the doGenerate and doStream
// spans is the provider prompt, ai.prompt on the parent generateText and streamText spans is {system,
// prompt} or {messages}, ai.response.text and ai.response.toolCalls hold the answer:
// https://ai-sdk.dev/docs/ai-sdk-core/telemetry
// The prompt parts in the provider's v1 spelling, image with mimeType, tool-call args and tool-result
// result, or v2, file with mediaType, tool-call input and tool-result output:
// https://github.com/vercel/ai/blob/47bd16dd50283c82a5fef4c839d7f4102c45f5b9/packages/provider/src/language-model/v1/language-model-v1-prompt.ts#L120
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-prompt.ts#L93
// The span mapper moves ai.prompt.messages and one of the response keys onto gen_ai.*.messages; the other
// response key and ai.prompt are read where they are.
// Written by: the Vercel AI SDK, Mastra.
// Handled: text, reasoning, tool-call, tool-result, image and file parts as a data URL, a URL, bare base64
// or a serialised Buffer; ai.response.finishReason.
const (
vercelPrompt = "ai.prompt"
vercelOperation = "ai.operationId"
vercelResponseText = "ai.response.text"
vercelResponseCalls = "ai.response.toolCalls"
vercelResponseFinished = "ai.response.finishReason"
)
func (f *formatting) vercel() bool {
if attribute(f.attributes, vercelOperation) == nil && attribute(f.attributes, vercelPrompt) == nil && attribute(f.attributes, "ai.response") == nil {
return false
}
switch input := decode(attribute(f.attributes, GenAIInputMessages)).(type) {
case []any:
f.out.Input = inputMessages(vercelMessages(input))
case nil:
if prompt, ok := decode(attribute(f.attributes, vercelPrompt)).(map[string]any); ok {
f.out.Input = inputMessages(vercelParentPrompt(prompt))
}
default:
f.out.Input = inputMessages(OutputMessages{genericMessage(input)})
f.warn("input is not a message list, kept as generic")
}
text, calls := attribute(f.attributes, vercelResponseText), decode(attribute(f.attributes, vercelResponseCalls))
switch output := decode(attribute(f.attributes, GenAIOutputMessages)).(type) {
case string:
text = output
case []any:
calls = output
}
msg := OutputMessage{Role: RoleAssistant, Parts: Parts{}, FinishReason: finishReason(stringOf(attribute(f.attributes, vercelResponseFinished)))}
if s := stringOf(text); s != "" {
msg.Parts = append(msg.Parts, part(textPart(s)))
}
if list, ok := calls.([]any); ok {
for _, item := range list {
if call, ok := item.(map[string]any); ok {
msg.Parts = append(msg.Parts, vercelToolCall(call))
}
}
}
if len(msg.Parts) > 0 {
f.out.Output = OutputMessages{msg}
}
return true
}
// vercelParentPrompt reads the parent span's ai.prompt: the system prompt with either a bare prompt or
// the messages.
func vercelParentPrompt(prompt map[string]any) OutputMessages {
var msgs OutputMessages
if parts := systemParts(prompt["system"]); len(parts) > 0 {
msgs = append(msgs, OutputMessage{Role: RoleSystem, Parts: parts})
}
if messages, ok := prompt["messages"].([]any); ok {
return append(msgs, vercelMessages(messages)...)
}
if text := stringOf(prompt["prompt"]); text != "" {
msgs = append(msgs, OutputMessage{Role: RoleUser, Parts: Parts{part(textPart(text))}})
}
return msgs
}
func vercelMessages(list []any) OutputMessages {
out := make(OutputMessages, 0, len(list))
for _, item := range list {
m, ok := item.(map[string]any)
if !ok {
out = append(out, genericMessage(item))
continue
}
msg := OutputMessage{Role: role(stringOf(m["role"])), Parts: Parts{}}
switch content := m["content"].(type) {
case string:
if content != "" {
msg.Parts = append(msg.Parts, part(textPart(content)))
}
case []any:
for _, item := range content {
msg.Parts = append(msg.Parts, vercelPart(item))
}
}
out = append(out, msg)
}
return out
}
func vercelPart(item any) Part {
p, ok := item.(map[string]any)
if !ok {
return contentPart(item)
}
switch stringOf(p["type"]) {
case "text":
return part(textPart(stringOf(p["text"])))
case "reasoning":
return part(ReasoningPart{Type: PartTypeReasoning, Content: stringOf(p["text"])})
case "redacted-reasoning":
return part(ReasoningPart{Type: PartTypeReasoning})
case "tool-call":
return vercelToolCall(p)
case "tool-result":
response := p["result"]
if output, ok := p["output"].(map[string]any); ok {
response = output["value"]
}
return part(ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: optionalString(p["toolCallId"]), Response: response})
case "image":
mime := stringOf(p["mimeType"])
if mime == "" {
mime = stringOf(p["mediaType"])
}
if media, ok := vercelMedia(p["image"], mime, ModalityImage); ok {
return media
}
case "file":
mime := stringOf(p["mediaType"])
if mime == "" {
mime = stringOf(p["mimeType"])
}
if media, ok := vercelMedia(p["data"], mime, modalityOf(mime)); ok {
return media
}
}
return part(GenericPart(p))
}
// vercelToolCall reads {toolCallId, toolName, input} or the v1 {toolCallId, toolName, args}; the
// arguments may be a JSON string:
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-prompt.ts#L126
// https://github.com/vercel/ai/blob/47bd16dd50283c82a5fef4c839d7f4102c45f5b9/packages/provider/src/language-model/v1/language-model-v1-prompt.ts#L175
func vercelToolCall(call map[string]any) Part {
args := call["input"]
if args == nil {
args = call["args"]
}
return part(ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(call["toolName"]), ID: optionalString(call["toolCallId"]), Arguments: decode(args)})
}
// vercelMedia reads a data URL, a URL, bare base64 or a serialised Node Buffer {type: Buffer,
// data: [bytes]}, which is how the parent span's ai.prompt carries an image given as bytes.
func vercelMedia(value any, mimeType string, modality Modality) (Part, bool) {
switch v := value.(type) {
case string:
if v == "" {
return Part{}, false
}
if strings.Contains(v, "://") || strings.HasPrefix(v, "data:") {
return part(mediaPart(v, modality)), true
}
return part(BlobPart{Type: PartTypeBlob, Modality: modality, Content: v, MimeType: optionalString(mimeType)}), true
case map[string]any:
bytes, ok := v["data"].([]any)
if !ok {
return Part{}, false
}
raw := make([]byte, 0, len(bytes))
for _, b := range bytes {
n, _ := b.(float64)
raw = append(raw, byte(n))
}
return part(BlobPart{Type: PartTypeBlob, Modality: modality, Content: base64.StdEncoding.EncodeToString(raw), MimeType: optionalString(mimeType)}), true
}
return Part{}, false
}

View File

@@ -1,159 +0,0 @@
package aiobservabilitytypes
import "testing"
// Captured cases are the ai.* attributes as the collector stored them for the SigNoz/scripts vercel and
// mastra captures, the AI SDK v4 shapes; the v5 cases follow the provider's v2 prompt types at the
// linked lines.
func TestFormat_Vercel(t *testing.T) {
runFormatCases(t, []formatCase{
{
// vercel capture, ai.generateText.doGenerate span after a tool round: v1 tool-call args and
// tool-result result
// https://github.com/vercel/ai/blob/47bd16dd50283c82a5fef4c839d7f4102c45f5b9/packages/provider/src/language-model/v1/language-model-v1-prompt.ts#L175
name: "V4ToolRound_ToolCallAndResultParts",
attributes: map[string]any{
"ai.operationId": "ai.generateText.doGenerate",
"ai.response.finishReason": "stop",
GenAIInputMessages: `[{"role":"user","content":[{"type":"text","text":"What is the weather in Paris and in London, in celsius? Use the tool for each."}]},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_isBPb3o3cqhY8OpugQntLOgy","toolName":"get_current_weather","args":{"city":"Paris","unit":"c"}},{"type":"tool-call","toolCallId":"call_sH9xd5EWFkiSaWmYvg4CeWTl","toolName":"get_current_weather","args":{"city":"London","unit":"c"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_isBPb3o3cqhY8OpugQntLOgy","toolName":"get_current_weather","result":{"city":"Paris","unit":"c","temperature":18,"condition":"clear"}},{"type":"tool-result","toolCallId":"call_sH9xd5EWFkiSaWmYvg4CeWTl","toolName":"get_current_weather","result":{"city":"London","unit":"c","temperature":18,"condition":"clear"}}]}]`,
GenAIOutputMessages: "The current weather is as follows:\n\n- **Paris**: 18°C, clear\n- **London**: 18°C, clear",
},
wantInput: `[
{"role": "user", "parts": [{"type": "text", "content": "What is the weather in Paris and in London, in celsius? Use the tool for each."}]},
{"role": "assistant", "parts": [
{"type": "tool_call", "name": "get_current_weather", "id": "call_isBPb3o3cqhY8OpugQntLOgy", "arguments": {"city": "Paris", "unit": "c"}},
{"type": "tool_call", "name": "get_current_weather", "id": "call_sH9xd5EWFkiSaWmYvg4CeWTl", "arguments": {"city": "London", "unit": "c"}}
]},
{"role": "tool", "parts": [
{"type": "tool_call_response", "id": "call_isBPb3o3cqhY8OpugQntLOgy", "response": {"city": "Paris", "unit": "c", "temperature": 18, "condition": "clear"}},
{"type": "tool_call_response", "id": "call_sH9xd5EWFkiSaWmYvg4CeWTl", "response": {"city": "London", "unit": "c", "temperature": 18, "condition": "clear"}}
]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "The current weather is as follows:\n\n- **Paris**: 18°C, clear\n- **London**: 18°C, clear"}], "finish_reason": "stop"}]`,
},
{
// vercel capture, ai.streamText.doStream span: the mapper moved ai.response.toolCalls onto the output
// and left the empty ai.response.text behind
name: "V4ToolCalls_ToolCallParts_EmptyTextIgnored",
attributes: map[string]any{
"ai.operationId": "ai.streamText.doStream",
"ai.response.finishReason": "tool-calls",
"ai.response.text": "",
GenAIInputMessages: `[{"role":"user","content":[{"type":"text","text":"What is the weather in Bengaluru in celsius?"}]}]`,
GenAIOutputMessages: `[{"type":"tool-call","toolCallId":"call_l0ycKcmt1uPCdGWR4SkwoLBv","toolName":"get_current_weather","args":{"city":"Bengaluru","unit":"c"}}]`,
},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "What is the weather in Bengaluru in celsius?"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_l0ycKcmt1uPCdGWR4SkwoLBv", "arguments": {"city": "Bengaluru", "unit": "c"}}], "finish_reason": "tool_call"}]`,
},
{
// vercel capture, ai.generateText parent span: ai.prompt is unmapped, the answer text was moved
name: "ParentPromptWithSystem_SystemAndUserMessages",
attributes: map[string]any{
"ai.operationId": "ai.generateText",
"ai.prompt": `{"system":"You are concise.","prompt":"Say hello in five words."}`,
"ai.response.finishReason": "stop",
GenAIOutputMessages: "Hello! How are you today?",
},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are concise."}]},
{"role": "user", "parts": [{"type": "text", "content": "Say hello in five words."}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello! How are you today?"}], "finish_reason": "stop"}]`,
},
{
// vercel capture, ai.generateText parent span of a vision call: the image is a serialised Node Buffer
name: "ParentPromptMessages_BufferImage_BlobPart",
attributes: map[string]any{
"ai.operationId": "ai.generateText",
"ai.prompt": `{"messages":[{"role":"user","content":[{"type":"text","text":"What animal is in this image? One sentence."},{"type":"image","image":{"type":"Buffer","data":[137,80,78,71]},"mediaType":"image/png"}]}]}`,
GenAIOutputMessages: "A duck.",
},
wantInput: `[{"role": "user", "parts": [
{"type": "text", "content": "What animal is in this image? One sentence."},
{"type": "blob", "modality": "image", "mime_type": "image/png", "content": "iVBORw=="}
]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "A duck."}]}]`,
},
{
// the v2 prompt after a tool round: tool-call input and tool-result output
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-prompt.ts#L126
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-prompt.ts#L161
name: "V5ToolRound_InputAndOutputSpellings",
attributes: map[string]any{
"ai.operationId": "ai.generateText.doGenerate",
"ai.response.finishReason": "stop",
GenAIInputMessages: `[{"role":"system","content":"Use the tool."},{"role":"user","content":[{"type":"text","text":"Weather in Paris?"}]},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_1","toolName":"getWeather","input":{"city":"Paris"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_1","toolName":"getWeather","output":{"type":"json","value":{"city":"Paris","tempC":18,"summary":"Clear"}}}]}]`,
GenAIOutputMessages: "Paris is clear at 18C.",
},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "Use the tool."}]},
{"role": "user", "parts": [{"type": "text", "content": "Weather in Paris?"}]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "getWeather", "id": "call_1", "arguments": {"city": "Paris"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_1", "response": {"city": "Paris", "tempC": 18, "summary": "Clear"}}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Paris is clear at 18C."}], "finish_reason": "stop"}]`,
},
{
// ai.response.toolCalls in the v2 spelling, the arguments under input; the text the mapper left
// behind in ai.response.text comes first
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-prompt.ts#L126
name: "V5ToolCallsBesideText_TextThenToolCall",
attributes: map[string]any{
"ai.operationId": "ai.streamText.doStream",
"ai.response.finishReason": "tool-calls",
"ai.response.text": "Let me check.",
GenAIInputMessages: `[{"role":"user","content":[{"type":"text","text":"Weather in Paris? Use the tool."}]}]`,
GenAIOutputMessages: `[{"toolCallId":"call_2","toolName":"getWeather","input":{"city":"Paris"}}]`,
},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "Weather in Paris? Use the tool."}]}]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "text", "content": "Let me check."},
{"type": "tool_call", "name": "getWeather", "id": "call_2", "arguments": {"city": "Paris"}}
], "finish_reason": "tool_call"}]`,
},
{
// the v2 file part, data as bare base64 or a URL with its mediaType, a reasoning part, and a tool
// result given as text
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-prompt.ts#L93
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-prompt.ts#L187
name: "V5FileParts_BlobsByMediaType_URLAsUri",
attributes: map[string]any{
"ai.operationId": "ai.generateText.doGenerate",
GenAIInputMessages: `[{"role":"user","content":[{"type":"text","text":"What is in these?"},{"type":"file","mediaType":"image/png","data":"iVBORw0KGgo="},{"type":"file","mediaType":"application/pdf","filename":"a.pdf","data":"JVBERi0xLjQgc3R1Yg=="},{"type":"file","mediaType":"audio/wav","data":"https://example.com/a.wav"},{"type":"reasoning","text":"thinking"}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_1","toolName":"read","output":{"type":"text","value":"done"}}]}]`,
},
wantInput: `[
{"role": "user", "parts": [
{"type": "text", "content": "What is in these?"},
{"type": "blob", "modality": "image", "mime_type": "image/png", "content": "iVBORw0KGgo="},
{"type": "blob", "modality": "document", "mime_type": "application/pdf", "content": "JVBERi0xLjQgc3R1Yg=="},
{"type": "uri", "modality": "audio", "uri": "https://example.com/a.wav"},
{"type": "reasoning", "content": "thinking"}
]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_1", "response": "done"}]}
]`,
wantOutput: `[]`,
},
{
// mastra capture, ai.generateText.doGenerate span: the v4 shapes with the arguments as a JSON string
name: "MastraToolCalls_ToolCallPart",
attributes: map[string]any{
"ai.operationId": "ai.generateText.doGenerate",
"ai.response.finishReason": "tool-calls",
GenAIInputMessages: `[{"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?"}]}]`,
GenAIOutputMessages: `[{"toolCallType":"function","toolCallId":"call_526phoykytobsvLouzES805J","toolName":"getWeather","args":"{\"city\":\"Bengaluru\"}"}]`,
},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are a weather assistant. When asked about weather, call get-weather once per city and summarise."}]},
{"role": "user", "parts": [{"type": "text", "content": "What is the weather in Bengaluru?"}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "getWeather", "id": "call_526phoykytobsvLouzES805J", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]`,
},
{
// vercel capture, ai.generateText span of a failed call
name: "PromptOnlyNoResponse_InputOnly",
attributes: map[string]any{"ai.operationId": "ai.generateText", "ai.prompt": `{"prompt":"this should fail"}`},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "this should fail"}]}]`,
wantOutput: `[]`,
},
})
}

View File

@@ -1,49 +0,0 @@
package promotetypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
type IndexMaterializedPathsResult struct {
Indexed []*PromotePath `json:"indexed" required:"true"`
Skipped []*PromotePath `json:"skipped" required:"true"`
}
type PostableIndexMaterializedPaths struct {
Signal string `json:"signal" required:"true"`
DryRun bool `json:"dryRun"`
}
func (p *PostableIndexMaterializedPaths) Validate() error {
if p.Signal == "" {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "signal is required")
}
_, err := p.Target()
return err
}
func (p *PostableIndexMaterializedPaths) Target() (Target, error) {
return NewTargetFromText(p.Signal, telemetrytypes.FieldContextAttribute.StringValue())
}
// NewMaterializedPromotePath mirrors the type and granularity of the key's
// column indexes. Only the indexes are validated: the key was materialized on
// purpose, so the path checks for user input, such as cardinality, do not apply.
func NewMaterializedPromotePath(target Target, key *telemetrytypes.TelemetryFieldKey) (*PromotePath, error) {
indexes := make([]WrappedIndex, 0, len(key.Indexes))
for _, index := range key.Indexes {
indexes = append(indexes, WrappedIndex{FieldDataType: key.FieldDataType, Type: index.IndexType, Granularity: index.Granularity})
}
path := &PromotePath{
Signal: target.Entry.Signal.StringValue(),
Context: target.Entry.FieldContext.StringValue(),
Path: key.Name,
Promote: false,
Indexes: indexes,
}
if err := path.validateAndSetIndexDefaults(); err != nil {
return nil, err
}
return path, nil
}

View File

@@ -1,101 +0,0 @@
package promotetypes
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
func TestNewMaterializedPromotePath(t *testing.T) {
testCases := []struct {
name string
key *telemetrytypes.TelemetryFieldKey
wantIndexes []WrappedIndex
wantErr bool
}{
{
name: "StringPath_MirrorsColumnIndex",
key: &telemetrytypes.TelemetryFieldKey{Name: "user.id", FieldDataType: telemetrytypes.FieldDataTypeString, Indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{
{IndexType: "bloom_filter", Granularity: 4},
}},
wantIndexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "bloom_filter", Granularity: 4, JSONDataType: telemetrytypes.String}},
},
{
name: "StringPath_MirrorsEveryColumnIndex",
key: &telemetrytypes.TelemetryFieldKey{Name: "tenant.id", FieldDataType: telemetrytypes.FieldDataTypeString, Indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{
{IndexType: "tokenbf_v1(10240, 3, 0)", Granularity: 1},
{IndexType: "bloom_filter(0.01)", Granularity: 2},
}},
wantIndexes: []WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "tokenbf_v1(10240, 3, 0)", Granularity: 1, JSONDataType: telemetrytypes.String},
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "bloom_filter(0.01)", Granularity: 2, JSONDataType: telemetrytypes.String},
},
},
{
name: "NumberPath_Float64Index",
key: &telemetrytypes.TelemetryFieldKey{Name: "retry.count", FieldDataType: telemetrytypes.FieldDataTypeNumber, Indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{
{IndexType: "minmax", Granularity: 1},
}},
wantIndexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeNumber, Type: "minmax", Granularity: 1, JSONDataType: telemetrytypes.Float64}},
},
{
name: "UnsupportedIndexType_Rejected",
key: &telemetrytypes.TelemetryFieldKey{Name: "user.email", FieldDataType: telemetrytypes.FieldDataTypeString, Indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{
{IndexType: "text(tokenizer = 'splitByNonAlpha')", Granularity: 1},
}},
wantErr: true,
},
{
name: "BoolPath_Rejected",
key: &telemetrytypes.TelemetryFieldKey{Name: "user.active", FieldDataType: telemetrytypes.FieldDataTypeBool, Indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{
{IndexType: "bloom_filter", Granularity: 1},
}},
wantErr: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
path, err := NewMaterializedPromotePath(NewTracesAttributesTarget(), testCase.key)
if testCase.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, &PromotePath{
Signal: "traces",
Context: "attribute",
Path: testCase.key.Name,
Promote: false,
Indexes: testCase.wantIndexes,
}, path)
})
}
}
func TestPostableIndexMaterializedPathsValidate(t *testing.T) {
testCases := []struct {
name string
req PostableIndexMaterializedPaths
wantErr bool
}{
{name: "TracesSignal_Valid", req: PostableIndexMaterializedPaths{Signal: "traces", DryRun: true}},
{name: "MissingSignal_Rejected", req: PostableIndexMaterializedPaths{}, wantErr: true},
{name: "UnknownSignal_Rejected", req: PostableIndexMaterializedPaths{Signal: "events"}, wantErr: true},
{name: "LogsSignal_NoAttributeTarget_Rejected", req: PostableIndexMaterializedPaths{Signal: "logs"}, wantErr: true},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
err := testCase.req.Validate()
if testCase.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
})
}
}

View File

@@ -46,15 +46,6 @@ func ListPromotedPathsResources(ec coretypes.ExtractorContext) ([]coretypes.Reso
return fieldResources(signals)
}
func IndexMaterializedPathsResources(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
signalText := gjson.GetBytes(ec.RequestBody, "signal").String()
signal, ok := telemetrytypes.SignalFromText(signalText)
if !ok {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid signal: %s", signalText)
}
return fieldResources([]telemetrytypes.Signal{signal})
}
func fieldResources(signals []telemetrytypes.Signal) ([]coretypes.ResourceWithID, error) {
resources := make([]coretypes.ResourceWithID, 0, len(signals))
seen := make(map[string]struct{})

View File

@@ -112,45 +112,3 @@ func TestListPromotedPathsResources(t *testing.T) {
})
}
}
func TestIndexMaterializedPathsResources(t *testing.T) {
testCases := []struct {
name string
body string
want []string
wantErr bool
}{
{
name: "TracesSignal_TracesField",
body: `{"signal":"traces","dryRun":true}`,
want: []string{"traces-field"},
},
{
name: "LogsSignal_LogsField",
body: `{"signal":"logs"}`,
want: []string{"logs-field"},
},
{
name: "MissingSignal_Rejected",
body: `{}`,
wantErr: true,
},
{
name: "UnsupportedSignal_Rejected",
body: `{"signal":"metrics"}`,
wantErr: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
resources, err := IndexMaterializedPathsResources(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
if testCase.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, testCase.want, resourceKinds(resources))
})
}
}

View File

@@ -114,7 +114,29 @@ func (i *PromotePath) ValidateAndSetDefaults(target Target) error {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cardinal paths can not be promoted or indexed")
}
return i.validateAndSetIndexDefaults()
for idx, index := range i.Indexes {
if index.Type == "" {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "index type is required")
}
if err := validateIndexType(index.Type); err != nil {
return err
}
if index.Granularity <= 0 {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "index granularity must be greater than 0")
}
jsonDataType, ok := telemetrytypes.MappingFieldDataTypeToJSONDataType[index.FieldDataType]
if !ok {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid column type: %s", index.FieldDataType)
}
if !jsonDataType.IndexSupported {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "index is not supported for column type: %s", index.FieldDataType)
}
i.Indexes[idx].JSONDataType = jsonDataType
}
return nil
}
func (index WrappedIndex) SkipIndexType() (schemamigrator.IndexType, error) {
@@ -200,29 +222,3 @@ func validateIndexType(indexType string) error {
}
return nil
}
func (i *PromotePath) validateAndSetIndexDefaults() error {
for idx, index := range i.Indexes {
if index.Type == "" {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "index type is required")
}
if err := validateIndexType(index.Type); err != nil {
return err
}
if index.Granularity <= 0 {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "index granularity must be greater than 0")
}
jsonDataType, ok := telemetrytypes.MappingFieldDataTypeToJSONDataType[index.FieldDataType]
if !ok {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid column type: %s", index.FieldDataType)
}
if !jsonDataType.IndexSupported {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "index is not supported for column type: %s", index.FieldDataType)
}
i.Indexes[idx].JSONDataType = jsonDataType
}
return nil
}

View File

@@ -45,9 +45,6 @@ type MetadataStore interface {
// rows templated by entry; FieldName and ReleaseTime are set per path.
PromotePaths(ctx context.Context, entry EvolutionEntry, paths ...string) error
// GetMaterializedKeys lists the keys materialized as dedicated columns.
GetMaterializedKeys(ctx context.Context, signal Signal) ([]*TelemetryFieldKey, error)
// GetFirstSeenFromMetricMetadata gets the first seen timestamp for a metric metadata lookup key.
GetFirstSeenFromMetricMetadata(ctx context.Context, lookupKeys []MetricMetadataLookupKey) (map[MetricMetadataLookupKey]int64, error)

View File

@@ -20,7 +20,6 @@ type MockMetadataStore struct {
ReducedMap map[string]bool
PromotedPathsMap map[telemetrytypes.Signal]map[string]bool
LogsJSONIndexes []telemetrytypes.TelemetryFieldKeySkipIndex
MaterializedKeys []*telemetrytypes.TelemetryFieldKey
ColumnEvolutionMetadataMap map[string][]*telemetrytypes.EvolutionEntry
LookupKeysMap map[telemetrytypes.MetricMetadataLookupKey]int64
// StaticFields holds signal-specific intrinsic field definitions (e.g. logstelemetryschema.IntrinsicFields).
@@ -377,16 +376,6 @@ func (m *MockMetadataStore) GetPromotedPaths(_ context.Context, entry telemetryt
return m.PromotedPathsMap[entry.Signal], nil
}
func (m *MockMetadataStore) GetMaterializedKeys(_ context.Context, signal telemetrytypes.Signal) ([]*telemetrytypes.TelemetryFieldKey, error) {
keys := []*telemetrytypes.TelemetryFieldKey{}
for _, key := range m.MaterializedKeys {
if key.Signal == signal {
keys = append(keys, key)
}
}
return keys, nil
}
// ListJSONIndexes narrows the stored indexes to the lookup's field context like the real query.
func (m *MockMetadataStore) ListJSONIndexes(ctx context.Context, lookup telemetrytypes.JSONIndexLookup, filters ...string) ([]telemetrytypes.TelemetryFieldKeySkipIndex, error) {
indexes := []telemetrytypes.TelemetryFieldKeySkipIndex{}

View File

@@ -32,7 +32,6 @@ pytest_plugins = [
"fixtures.alerts",
"fixtures.cloudintegrations",
"fixtures.jsontypes",
"fixtures.materialized",
"fixtures.seeder",
"fixtures.serviceaccount",
"fixtures.role",

View File

@@ -1,37 +0,0 @@
from collections.abc import Callable, Generator
import pytest
from fixtures import types
# signal -> (database, local table, distributed table)
MATERIALIZED_TABLES = {
"traces": ("signoz_traces", "signoz_index_v3", "distributed_signoz_index_v3"),
}
@pytest.fixture(name="materialize_attribute", scope="function")
def materialize_attribute(signoz: types.SigNoz) -> Generator[Callable[[str, str, bool], str]]:
"""Teardown drops the columns and every index whose expression mentions a materialized key."""
cluster = signoz.telemetrystore.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"]
created: list[tuple[str, str, str]] = []
def materialize(signal: str, key: str, indexed: bool) -> str:
database, local_table, distributed_table = MATERIALIZED_TABLES[signal]
column = f"attribute_string_{key.replace('.', '$$')}"
created.append((signal, key, column))
for table in (local_table, distributed_table):
signoz.telemetrystore.conn.query(f"ALTER TABLE {database}.{table} ON CLUSTER '{cluster}' ADD COLUMN IF NOT EXISTS `{column}` LowCardinality(String) DEFAULT attributes_string['{key}']")
if indexed:
signoz.telemetrystore.conn.query(f"ALTER TABLE {database}.{local_table} ON CLUSTER '{cluster}' ADD INDEX IF NOT EXISTS `{column}_idx` `{column}` TYPE bloom_filter(0.01) GRANULARITY 64")
return column
yield materialize
for signal, key, column in created:
database, local_table, distributed_table = MATERIALIZED_TABLES[signal]
indexes = signoz.telemetrystore.conn.query(f"SELECT name FROM system.data_skipping_indices WHERE database = '{database}' AND table = '{local_table}' AND (expr LIKE '%{key}%' OR expr LIKE '%{column}%')").result_rows
for (index_name,) in indexes:
signoz.telemetrystore.conn.query(f"ALTER TABLE {database}.{local_table} ON CLUSTER '{cluster}' DROP INDEX IF EXISTS `{index_name}`")
for table in (local_table, distributed_table):
signoz.telemetrystore.conn.query(f"ALTER TABLE {database}.{table} ON CLUSTER '{cluster}' DROP COLUMN IF EXISTS `{column}`")

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