Compare commits

..

14 Commits

Author SHA1 Message Date
vikrantgupta25
7c0dce31ed feat(licensing)!: remove v3 license endpoints 2026-08-31 18:11:10 +05:30
vikrantgupta25
f65c27c236 refactor(zeus): return typed license from the adapter 2026-08-31 18:11:09 +05:30
vikrantgupta25
405e92cd06 chore: merge main into feat/licensing-v4 2026-08-31 17:12:47 +05:30
vikrantgupta25
ce3966b8c4 refactor(licensing): simplify activate, cloud guard and active license route 2026-08-31 17:08:30 +05:30
vikrantgupta25
f2ce56de35 refactor(licensing): construct plan and event queue on the license struct 2026-08-31 16:05:10 +05:30
vikrantgupta25
5e06339afe test(licensing): drop license authz integration suite 2026-08-31 16:02:11 +05:30
vikrantgupta25
32f9254035 refactor(licensing): use camelcase for v4 license responses 2026-08-31 16:02:03 +05:30
Nikhil Soni
764fe8ec69 feat(logs): add pinned attributes preference support (#12687)
#### Description

Registers a new per-user preference `log_details_pinned_attributes` in
`pkg/types/preferencetypes`, following the same shape as the existing
`span_details_pinned_attributes` (trace-details pin feature, #11092).
2026-08-31 10:31:59 +00:00
vikrantgupta25
ea7fb4cdaa feat(licensing): add v4 license endpoints with resource authz 2026-08-31 14:54:15 +05:30
Srikanth Chekuri
da9b4644df fix(prometheus): set NoStepSubqueryIntervalFn to stop promql subquery segfault (#12720)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
#### Description

- A PromQL subquery without a step, for example
`max_over_time(metric[5m:])`, segfaulted the whole query-service. The
engine calls `NoStepSubqueryIntervalFn` for such subqueries, and we
build the engine without it, so the call hits a nil function.
- The bug is present on every PromQL surface, because all of them share
the one engine constructor in `pkg/prometheus/engine.go`: v3 and v5
`query_range`, `/api/v1/query`, the clickhousev2 transpiler, and promql
alert rules. A saved rule with such a subquery crash-loops the instance
on its own schedule.
- The fix sets the callback to 1m. This matches the Prometheus default
global `evaluation_interval`, which upstream wires into this field. One
place fixes every path.
- This is the root cause of the SigNoz/platform-pod#3068 incident. The
instance-hardening request from that incident is tracked in
SigNoz/pulse-pod#308.

#### Issues closed by this PR

Closes SigNoz/platform-pod#3068

#### Additional Information

We audited `EngineOpts` for more bugs of the same class.
`NoStepSubqueryIntervalFn` is the only field the engine calls without a
nil guard; `promql.NewEngine` defaults the other nil-able fields
(`Parser`, `FeatureRegistry`). The remaining gaps against upstream
wiring are not crashes, and we filed them separately:
SigNoz/pulse-pod#305 (`@` modifier and negative offset disabled),
SigNoz/pulse-pod#306 (engine self-metrics not registered),
SigNoz/pulse-pod#307 (active query tracker startup panic risk),
SigNoz/pulse-pod#309 (step guard in the v3 cache), SigNoz/pulse-pod#310
(upstream proposal to fail fast on the nil callback).

Tests for the bug:

- `pkg/prometheus/engine_test.go` — fails with the exact segfault when
the fix is removed.
- `tests/integration/tests/promqlconformance/04_no_step_subquery.py` — a
step-less subquery through `/api/v5/query_range` returns correct values
on both providers, and the service stays up.
- `tests/integration/tests/alerts/04_promql_subquery_no_step.py` — a
promql alert rule with a step-less subquery evaluates and fires.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-08-29 09:26:57 +00:00
Aditya Singh
095821264e fix(explorer): guard saved-view URL params against non-JSON values (#12706)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Opening an explorer with a `viewName`/`viewKey` in the URL that isn't
valid JSON crashed the whole page.
- The hook ran `JSON.parse` on the raw param during render, so a bare
saved-view name threw error and broke page.
- Fix: wrap `JSON.parse` in try/catch and fall back to the raw string.
- Renamed the hook to `useGetSavedViewParams` and moved it under
`hooks/saveViews`; it only reads `viewName`/`viewKey` so the old
query-builder name/location was misleading. Now returns `{ viewName,
viewKey }` directly.
- Behavior preserved for all consumers; added tests for the non-JSON
case.


Closes https://github.com/SigNoz/engineering-pod/issues/5638

Screenshots/Recording

Before:

Test url: Just remove quotes from viewKey or viewName:
[url](https://app.us.staging.signoz.cloud/logs/logs-explorer?relativeTime=1month&compositeQuery=%257B%2522queryType%2522%253A%2522builder%2522%252C%2522builder%2522%253A%257B%2522queryData%2522%253A%255B%257B%2522dataSource%2522%253A%2522logs%2522%252C%2522queryName%2522%253A%2522A%2522%252C%2522aggregateOperator%2522%253A%2522count%2522%252C%2522aggregateAttribute%2522%253A%257B%2522id%2522%253A%2522----%2522%252C%2522dataType%2522%253A%2522%2522%252C%2522key%2522%253A%2522%2522%252C%2522type%2522%253A%2522%2522%257D%252C%2522timeAggregation%2522%253A%2522rate%2522%252C%2522spaceAggregation%2522%253A%2522sum%2522%252C%2522filter%2522%253A%257B%2522expression%2522%253A%2522%2522%257D%252C%2522aggregations%2522%253A%255B%257B%2522expression%2522%253A%2522count%28%29%2522%257D%255D%252C%2522functions%2522%253Anull%252C%2522filters%2522%253A%257B%2522items%2522%253A%255B%257B%2522id%2522%253A%2522228b8a2f-d6ba-4704-9104-936e91a2c119%2522%252C%2522key%2522%253A%257B%2522id%2522%253A%2522code.function--string--tag%2522%252C%2522dataType%2522%253A%2522string%2522%252C%2522key%2522%253A%2522code.function%2522%252C%2522type%2522%253A%2522tag%2522%257D%252C%2522op%2522%253A%2522%253D%2522%252C%2522value%2522%253A%2522render_test%2522%257D%255D%252C%2522op%2522%253A%2522AND%2522%257D%252C%2522expression%2522%253A%2522A%2522%252C%2522disabled%2522%253Afalse%252C%2522stepInterval%2522%253A0%252C%2522having%2522%253A%257B%2522expression%2522%253A%2522%2522%257D%252C%2522limit%2522%253Anull%252C%2522orderBy%2522%253A%255B%255D%252C%2522groupBy%2522%253A%255B%255D%252C%2522legend%2522%253A%2522%2522%252C%2522reduceTo%2522%253A%2522avg%2522%252C%2522source%2522%253A%2522%2522%252C%2522name%2522%253A%2522A%2522%252C%2522signal%2522%253A%2522logs%2522%252C%2522order%2522%253Anull%252C%2522selectFields%2522%253Anull%252C%2522secondaryAggregations%2522%253Anull%257D%255D%252C%2522queryFormulas%2522%253A%255B%255D%252C%2522queryTraceOperator%2522%253A%255B%255D%257D%252C%2522promql%2522%253A%255B%257B%2522name%2522%253A%2522A%2522%252C%2522query%2522%253A%2522%2522%252C%2522legend%2522%253A%2522%2522%252C%2522disabled%2522%253Afalse%257D%255D%252C%2522clickhouse_sql%2522%253A%255B%257B%2522name%2522%253A%2522A%2522%252C%2522legend%2522%253A%2522%2522%252C%2522disabled%2522%253Afalse%252C%2522query%2522%253A%2522%2522%257D%255D%252C%2522id%2522%253A%2522bfb926e4-7b98-4cf4-bd4d-adcbd18a1da2%2522%252C%2522unit%2522%253A%2522%2522%257D&options=%7B%22selectColumns%22%3A%5B%7B%22name%22%3A%22timestamp%22%2C%22signal%22%3A%22logs%22%2C%22fieldContext%22%3A%22log%22%2C%22fieldDataType%22%3A%22%22%7D%2C%7B%22name%22%3A%22lkadsjfl%22%2C%22signal%22%3A%22%22%2C%22fieldContext%22%3A%22%22%2C%22fieldDataType%22%3A%22%22%7D%2C%7B%22name%22%3A%22body%22%2C%22signal%22%3A%22logs%22%2C%22fieldContext%22%3A%22log%22%2C%22fieldDataType%22%3A%22%22%7D%2C%7B%22name%22%3A%22test%22%2C%22signal%22%3A%22%22%2C%22fieldContext%22%3A%22%22%2C%22fieldDataType%22%3A%22%22%7D%2C%7B%22name%22%3A%22severity_text%22%2C%22description%22%3A%22Log+level.+Learn+more+%5Bhere%5D%28https%3A%2F%2Fopentelemetry.io%2Fdocs%2Fspecs%2Fotel%2Flogs%2Fdata-model%2F%23field-severitytext%29%22%2C%22signal%22%3A%22logs%22%2C%22fieldContext%22%3A%22log%22%2C%22fieldDataType%22%3A%22string%22%7D%5D%2C%22format%22%3A%22list%22%2C%22maxLines%22%3A1%2C%22fontSize%22%3A%22small%22%7D&panelTypes=%22list%22&viewName=%22test+manul+key%22&viewKey=068e4a96-5225-4abe-8f9b-a5009f26d4ce)

Breaks page

<img width="3456" height="1720" alt="image"
src="https://github.com/user-attachments/assets/4d7546a3-b5cb-4be3-bc9f-e0782446f7f1"
/>



#### Additional Information

Sentry: https://signoz-io.sentry.io/issues/7520808361
2026-08-28 06:40:25 +00:00
Vikrant Gupta
5069bf80b0 feat(authz): enable FGA for deployment hosts (#12699)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
#### Description

- Deployment host routes (`GET`/`PUT /api/v2/zeus/hosts`) now use
`CheckResources` + `ResourceDef` instead of the coarse
`ViewAccess`/`AdminAccess` gates — per-resource FGA checks on
enterprise, role gate on community.
- New `deployment-host` metaresource kind with `list`/`update` verbs —
the GET returns the deployment's host collection and the PUT upserts the
single editable host. Admins get `list`+`update`, editors and viewers
get `list`, preserving current behavior.
- Migration `118_add_deployment_host_tuples` backfills the tuples for
existing organizations and re-syncs the stored managed-role transaction
groups; new organizations get both from the registry at bootstrap.
- Regenerated OpenAPI spec and transaction-groups schema: the operations
advertise `deployment-host:list`/`deployment-host:update` scopes instead
of `VIEWER`/`ADMIN`.
- Added `deploymenthost/01_authz.py` covering managed-role gating,
custom-role `list`/`update` grants, and rejection of verbs the resource
does not support.

#### Issues closed by this PR

Closes SigNoz/platform-pod#2652
2026-08-27 13:22:27 +00:00
Aditya Singh
1086914a3b fix(query-builder): prevent qb crash on partial queries (#12705)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- traces explorer was crashing on some shared/legacy links...the query
builder reads `builder.queryFormulas` and `queryTraceOperator` directly
and these can be missing from a partial compositeQuery (old saved view,
shared link, older release)
- fixed it where the query enters state from the url param, defaulting
the arrays to `[]`...this way every consumer gets `[]` and not
undefined, not just the component that crashed
- also default them in `prepareQueryBuilderData` so any query loaded
into the provider is normalized
- optional chained the reads in `QueryBuilderV2` as a render level
safety net
- added a test that a partial query (missing these arrays) renders
without crashing

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/engineering-pod/issues/5949
Closes https://github.com/SigNoz/engineering-pod/issues/4320
Closes https://github.com/SigNoz/engineering-pod/issues/3335
Closes https://github.com/SigNoz/engineering-pod/issues/4314

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

[Test URL
Link](https://app.us.staging.signoz.cloud/traces-explorer?relativeTime=1h&compositeQuery=%257B%2522queryType%2522%253A%2522builder%2522%252C%2522builder%2522%253A%257B%2522queryData%2522%253A%255B%257B%2522dataSource%2522%253A%2522logs%2522%252C%2522queryName%2522%253A%2522A%2522%252C%2522aggregateOperator%2522%253A%2522noop%2522%252C%2522aggregateAttribute%2522%253A%257B%2522id%2522%253A%2522----%2522%252C%2522dataType%2522%253A%2522%2522%252C%2522key%2522%253A%2522%2522%252C%2522type%2522%253A%2522%2522%257D%252C%2522timeAggregation%2522%253A%2522rate%2522%252C%2522spaceAggregation%2522%253A%2522sum%2522%252C%2522filter%2522%253A%257B%2522expression%2522%253A%2522service.name%2520%253D%2520%27midtier-api-production%27%2520AND%2520severity_text%2520%253D%2520%27ERROR%27%2522%257D%252C%2522aggregations%2522%253A%255B%257B%2522expression%2522%253A%2522count%28%29%2522%257D%255D%252C%2522functions%2522%253A%255B%255D%252C%2522filters%2522%253A%257B%2522items%2522%253A%255B%255D%252C%2522op%2522%253A%2522AND%2522%257D%252C%2522expression%2522%253A%2522A%2522%252C%2522disabled%2522%253Afalse%252C%2522stepInterval%2522%253Anull%252C%2522having%2522%253A%257B%2522expression%2522%253A%2522%2522%257D%252C%2522limit%2522%253Anull%252C%2522orderBy%2522%253A%255B%255D%252C%2522groupBy%2522%253A%255B%255D%252C%2522legend%2522%253A%2522%2522%252C%2522reduceTo%2522%253A%2522avg%2522%252C%2522source%2522%253A%2522%2522%257D%255D%257D%252C%2522promql%2522%253A%255B%257B%2522name%2522%253A%2522A%2522%252C%2522query%2522%253A%2522%2522%252C%2522legend%2522%253A%2522%2522%252C%2522disabled%2522%253Afalse%257D%255D%252C%2522clickhouse_sql%2522%253A%255B%257B%2522name%2522%253A%2522A%2522%252C%2522legend%2522%253A%2522%2522%252C%2522disabled%2522%253Afalse%252C%2522query%2522%253A%2522%2522%257D%255D%252C%2522id%2522%253A%25225d398425-eb7e-41e4-990a-de9375baf74a%2522%252C%2522unit%2522%253A%2522%2522%257D&options=%7B%22selectColumns%22%3A%5B%7B%22name%22%3A%22timestamp%22%2C%22signal%22%3A%22logs%22%2C%22fieldContext%22%3A%22log%22%2C%22fieldDataType%22%3A%22%22%2C%22isIndexed%22%3Afalse%7D%2C%7B%22name%22%3A%22body%22%2C%22signal%22%3A%22logs%22%2C%22fieldContext%22%3A%22log%22%2C%22fieldDataType%22%3A%22%22%2C%22isIndexed%22%3Afalse%7D%5D%2C%22maxLines%22%3A1%2C%22format%22%3A%22raw%22%2C%22fontSize%22%3A%22small%22%7D)

Before:
<img width="3452" height="1780" alt="image"
src="https://github.com/user-attachments/assets/4f1e871f-6063-4aeb-93b2-a902d57abdb9"
/>


After:
<img width="3002" height="1648" alt="image"
src="https://github.com/user-attachments/assets/645411d8-3462-4701-9694-1ea743ac2e17"
/>


<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
Pager: https://signoz-1.pagerduty.com/incidents/Q3YCF0KG3OYSYX

Sentry:https://signoz-io.sentry.io/issues/7498496470/?referrer=pagerduty_integration&notification_uuid=5cd4dc50-634b-416f-ab84-9540706c43aa

<!--Please delete paragraphs that you did not use before submitting.-->
2026-08-27 12:43:34 +00:00
Vikrant Gupta
3e2daaea0e test(service-account): fix flaky ServiceAccountDrawer authz test (#12708)
#### Description

- The `shows PermissionDeniedCallout in Keys tab when list-keys
permission is denied` test intermittently failed in CI: the
`fireEvent.click` on the Keys tab races with the nuqs testing adapter,
which can abort the queued `tab=keys` URL update mid-flight, leaving the
drawer stuck on the Overview tab.
- Since the tab is URL state, the test now lands directly on the Keys
tab via initial search params (`{ account: 'sa-1', tab: 'keys' }`),
avoiding the userEvent/click interaction altogether. The click-to-Keys
flow remains covered in `ServiceAccountDrawer.test.tsx`.

#### Issues closed by this PR

closes SigNoz/platform-pod#3053
2026-08-27 12:34:32 +00:00
65 changed files with 3008 additions and 888 deletions

View File

@@ -2559,6 +2559,7 @@ components:
- factor-api-key
- license
- subscription
- deployment-host
- logs
- traces
- metrics
@@ -5641,6 +5642,171 @@ components:
- total
- endTimeBeforeRetention
type: object
LicensetypesFeature:
properties:
active:
type: boolean
name:
type: string
route:
type: string
usage:
format: int64
type: integer
usage_limit:
format: int64
type: integer
type: object
LicensetypesGettableLicense:
properties:
createdAt:
format: date-time
type: string
eventQueue:
$ref: '#/components/schemas/LicensetypesLicenseEventQueue'
features:
items:
$ref: '#/components/schemas/LicensetypesFeature'
nullable: true
type: array
freeUntil:
format: date-time
type: string
id:
type: string
plan:
$ref: '#/components/schemas/LicensetypesLicensePlan'
platform:
type: string
state:
type: string
status:
type: string
updatedAt:
format: date-time
type: string
validFrom:
format: int64
type: integer
validUntil:
format: int64
type: integer
required:
- id
- validFrom
- validUntil
- status
- state
- platform
- freeUntil
- createdAt
- updatedAt
- plan
- features
- eventQueue
type: object
LicensetypesGettableLicenseWithKey:
properties:
createdAt:
format: date-time
type: string
eventQueue:
$ref: '#/components/schemas/LicensetypesLicenseEventQueue'
features:
items:
$ref: '#/components/schemas/LicensetypesFeature'
nullable: true
type: array
freeUntil:
format: date-time
type: string
id:
type: string
key:
type: string
plan:
$ref: '#/components/schemas/LicensetypesLicensePlan'
platform:
type: string
state:
type: string
status:
type: string
updatedAt:
format: date-time
type: string
validFrom:
format: int64
type: integer
validUntil:
format: int64
type: integer
required:
- id
- validFrom
- validUntil
- status
- state
- platform
- freeUntil
- createdAt
- updatedAt
- plan
- features
- eventQueue
- key
type: object
LicensetypesLicenseEventQueue:
properties:
createdAt:
format: date-time
type: string
event:
type: string
scheduledAt:
format: date-time
type: string
status:
type: string
updatedAt:
format: date-time
type: string
required:
- event
- status
- scheduledAt
- createdAt
- updatedAt
type: object
LicensetypesLicensePlan:
properties:
createdAt:
format: date-time
type: string
description:
type: string
id:
type: string
isActive:
type: boolean
name:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- description
- isActive
- createdAt
- updatedAt
type: object
LicensetypesPostableLicense:
properties:
key:
type: string
type: object
LlmpricingruletypesGettablePricingRules:
properties:
items:
@@ -9222,10 +9388,6 @@ paths:
name: name
schema:
type: string
- in: query
name: existingQuery
schema:
type: string
responses:
"200":
content:
@@ -23946,9 +24108,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- deployment-host:list
- tokenizer:
- VIEWER
- deployment-host:list
summary: Get host info from Zeus.
tags:
- zeus
@@ -24002,9 +24164,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- deployment-host:update
- tokenizer:
- ADMIN
- deployment-host:update
summary: Put host in Zeus for a deployment.
tags:
- zeus
@@ -24203,6 +24365,347 @@ paths:
summary: Get flamegraph view for a trace
tags:
- tracedetail
/api/v4/licenses:
get:
deprecated: false
description: This endpoint lists all the licenses of the organization.
operationId: ListLicenses
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/LicensetypesGettableLicense'
type: array
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:
- license:list
- tokenizer:
- license:list
summary: List licenses.
tags:
- licenses
post:
deprecated: false
description: This endpoint validates the license key with the upstream server
and activates the license for the organization.
operationId: ActivateLicense
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/LicensetypesPostableLicense'
responses:
"202":
description: Accepted
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- license:create
- tokenizer:
- license:create
summary: Activate a license.
tags:
- licenses
/api/v4/licenses/{id}:
delete:
deprecated: false
description: This endpoint deletes the license by id. Licenses managed by SigNoz
Cloud cannot be deleted.
operationId: DeleteLicense
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- license:delete
- tokenizer:
- license:delete
summary: Delete a license.
tags:
- licenses
get:
deprecated: false
description: This endpoint gets the license by id.
operationId: GetLicense
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/LicensetypesGettableLicenseWithKey'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- license:read
- tokenizer:
- license:read
summary: Get a license.
tags:
- licenses
put:
deprecated: false
description: This endpoint refreshes the active license of the organization
from the upstream server.
operationId: RefreshLicense
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- license:update
- tokenizer:
- license:update
summary: Refresh a license.
tags:
- licenses
/api/v4/orgs/me/license:
get:
deprecated: false
description: This endpoint gets the active license of the organization.
operationId: GetActiveLicense
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/LicensetypesGettableLicense'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key: []
- tokenizer: []
summary: Get the active license.
tags:
- licenses
/api/v4/traces/{traceID}/waterfall:
post:
deprecated: false

View File

@@ -22,89 +22,6 @@ func NewLicensingAPI(licensing licensing.Licensing) licensing.API {
return &licensingAPI{licensing: licensing}
}
func (api *licensingAPI) Activate(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid"))
return
}
req := new(licensetypes.PostableLicense)
err = json.NewDecoder(r.Body).Decode(&req)
if err != nil {
render.Error(rw, err)
return
}
err = api.licensing.Activate(r.Context(), orgID, req.Key)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusAccepted, nil)
}
func (api *licensingAPI) GetActive(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid"))
return
}
license, err := api.licensing.GetActive(r.Context(), orgID)
if err != nil {
render.Error(rw, err)
return
}
gettableLicense := licensetypes.NewGettableLicense(license.Data, license.Key)
render.Success(rw, http.StatusOK, gettableLicense)
}
func (api *licensingAPI) Refresh(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid"))
return
}
err = api.licensing.Refresh(r.Context(), orgID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (api *licensingAPI) Checkout(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()

View File

@@ -96,12 +96,12 @@ func (provider *provider) Validate(ctx context.Context) error {
}
func (provider *provider) Activate(ctx context.Context, organizationID valuer.UUID, key string) error {
data, err := provider.zeus.GetLicense(ctx, key)
zeusLicense, err := provider.zeus.GetLicense(ctx, key)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to fetch license data with upstream server")
}
license, err := licensetypes.NewLicense(data, organizationID)
license, err := licensetypes.NewLicense(zeusLicense, organizationID)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to create license entity")
}
@@ -115,6 +115,47 @@ func (provider *provider) Activate(ctx context.Context, organizationID valuer.UU
return nil
}
func (provider *provider) Get(ctx context.Context, organizationID valuer.UUID, licenseID valuer.UUID) (*licensetypes.License, error) {
storableLicense, err := provider.store.Get(ctx, organizationID, licenseID)
if err != nil {
return nil, err
}
return licensetypes.NewLicenseFromStorableLicense(storableLicense)
}
func (provider *provider) List(ctx context.Context, organizationID valuer.UUID) ([]*licensetypes.License, error) {
storableLicenses, err := provider.store.GetAll(ctx, organizationID)
if err != nil {
return nil, err
}
licenses := make([]*licensetypes.License, 0, len(storableLicenses))
for _, storableLicense := range storableLicenses {
license, err := licensetypes.NewLicenseFromStorableLicense(storableLicense)
if err != nil {
return nil, err
}
licenses = append(licenses, license)
}
return licenses, nil
}
func (provider *provider) Delete(ctx context.Context, organizationID valuer.UUID, licenseID valuer.UUID) error {
license, err := provider.Get(ctx, organizationID, licenseID)
if err != nil {
return err
}
if err := license.ErrIfCloud(); err != nil {
return errors.WithAdditionalf(err, "license %s cannot be deleted", licenseID.StringValue())
}
return provider.store.Delete(ctx, organizationID, licenseID)
}
func (provider *provider) GetActive(ctx context.Context, organizationID valuer.UUID) (*licensetypes.License, error) {
storableLicenses, err := provider.store.GetAll(ctx, organizationID)
if err != nil {
@@ -139,7 +180,7 @@ func (provider *provider) Refresh(ctx context.Context, organizationID valuer.UUI
return err
}
data, err := provider.zeus.GetLicense(ctx, activeLicense.Key)
zeusLicense, err := provider.zeus.GetLicense(ctx, activeLicense.Key)
if err != nil {
if time.Since(activeLicense.LastValidatedAt) > time.Duration(provider.config.FailureThreshold)*provider.config.PollInterval {
activeLicense.UpdateFeatures(licensetypes.BasicPlan)
@@ -154,7 +195,7 @@ func (provider *provider) Refresh(ctx context.Context, organizationID valuer.UUI
return err
}
err = activeLicense.Update(data)
err = activeLicense.Update(zeusLicense)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to create license entity from license data")
}

View File

@@ -64,6 +64,22 @@ func (store *store) GetAll(ctx context.Context, organizationID valuer.UUID) ([]*
return storableLicenses, nil
}
func (store *store) Delete(ctx context.Context, organizationID valuer.UUID, licenseID valuer.UUID) error {
_, err := store.
sqlstore.
BunDB().
NewDelete().
Model(new(licensetypes.StorableLicense)).
Where("org_id = ?", organizationID).
Where("id = ?", licenseID).
Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to delete license with ID: %s", licenseID)
}
return nil
}
func (store *store) Update(ctx context.Context, organizationID valuer.UUID, storableLicense *licensetypes.StorableLicense) error {
_, err := store.
sqlstore.

View File

@@ -76,11 +76,6 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/billing", am.AdminAccess(ah.getBilling)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/portal", am.AdminAccess(ah.LicensingAPI.Portal)).Methods(http.MethodPost)
// v3
router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Activate)).Methods(http.MethodPost)
router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Refresh)).Methods(http.MethodPut)
router.HandleFunc("/api/v3/licenses/active", am.ViewAccess(ah.LicensingAPI.GetActive)).Methods(http.MethodGet)
// v4
router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost)

View File

@@ -51,7 +51,7 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
}, nil
}
func (provider *Provider) GetLicense(ctx context.Context, key string) ([]byte, error) {
func (provider *Provider) GetLicense(ctx context.Context, key string) (*zeustypes.License, error) {
response, err := provider.do(
ctx,
provider.config.URL.JoinPath("/v2/licenses/me"),
@@ -63,7 +63,12 @@ func (provider *Provider) GetLicense(ctx context.Context, key string) ([]byte, e
return nil, err
}
return []byte(gjson.GetBytes(response, "data").String()), nil
license := new(zeustypes.License)
if err := json.Unmarshal([]byte(gjson.GetBytes(response, "data").String()), license); err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, zeus.ErrCodeResponseMalformed, "failed to unmarshal license data")
}
return license, nil
}
func (provider *Provider) GetCheckoutURL(ctx context.Context, key string, body []byte) ([]byte, error) {

View File

@@ -0,0 +1,538 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
DeleteLicensePathParameters,
GetActiveLicense200,
GetLicense200,
GetLicensePathParameters,
LicensetypesPostableLicenseDTO,
ListLicenses200,
RefreshLicensePathParameters,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* This endpoint lists all the licenses of the organization.
* @summary List licenses.
*/
export const listLicenses = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListLicenses200>({
url: `/api/v4/licenses`,
method: 'GET',
signal,
});
};
export const getListLicensesQueryKey = () => {
return [`/api/v4/licenses`] as const;
};
export const getListLicensesQueryOptions = <
TData = Awaited<ReturnType<typeof listLicenses>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listLicenses>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListLicensesQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listLicenses>>> = ({
signal,
}) => listLicenses(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listLicenses>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListLicensesQueryResult = NonNullable<
Awaited<ReturnType<typeof listLicenses>>
>;
export type ListLicensesQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List licenses.
*/
export function useListLicenses<
TData = Awaited<ReturnType<typeof listLicenses>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listLicenses>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListLicensesQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List licenses.
*/
export const invalidateListLicenses = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListLicensesQueryKey() },
options,
);
return queryClient;
};
/**
* This endpoint validates the license key with the upstream server and activates the license for the organization.
* @summary Activate a license.
*/
export const activateLicense = (
licensetypesPostableLicenseDTO?: BodyType<LicensetypesPostableLicenseDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v4/licenses`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: licensetypesPostableLicenseDTO,
signal,
});
};
export const getActivateLicenseMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof activateLicense>>,
TError,
{ data?: BodyType<LicensetypesPostableLicenseDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof activateLicense>>,
TError,
{ data?: BodyType<LicensetypesPostableLicenseDTO> },
TContext
> => {
const mutationKey = ['activateLicense'];
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 activateLicense>>,
{ data?: BodyType<LicensetypesPostableLicenseDTO> }
> = (props) => {
const { data } = props ?? {};
return activateLicense(data);
};
return { mutationFn, ...mutationOptions };
};
export type ActivateLicenseMutationResult = NonNullable<
Awaited<ReturnType<typeof activateLicense>>
>;
export type ActivateLicenseMutationBody =
| BodyType<LicensetypesPostableLicenseDTO>
| undefined;
export type ActivateLicenseMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Activate a license.
*/
export const useActivateLicense = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof activateLicense>>,
TError,
{ data?: BodyType<LicensetypesPostableLicenseDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof activateLicense>>,
TError,
{ data?: BodyType<LicensetypesPostableLicenseDTO> },
TContext
> => {
return useMutation(getActivateLicenseMutationOptions(options));
};
/**
* This endpoint deletes the license by id. Licenses managed by SigNoz Cloud cannot be deleted.
* @summary Delete a license.
*/
export const deleteLicense = (
{ id }: DeleteLicensePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v4/licenses/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteLicenseMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteLicense>>,
TError,
{ pathParams: DeleteLicensePathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteLicense>>,
TError,
{ pathParams: DeleteLicensePathParameters },
TContext
> => {
const mutationKey = ['deleteLicense'];
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 deleteLicense>>,
{ pathParams: DeleteLicensePathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteLicense(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteLicenseMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteLicense>>
>;
export type DeleteLicenseMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete a license.
*/
export const useDeleteLicense = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteLicense>>,
TError,
{ pathParams: DeleteLicensePathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteLicense>>,
TError,
{ pathParams: DeleteLicensePathParameters },
TContext
> => {
return useMutation(getDeleteLicenseMutationOptions(options));
};
/**
* This endpoint gets the license by id.
* @summary Get a license.
*/
export const getLicense = (
{ id }: GetLicensePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetLicense200>({
url: `/api/v4/licenses/${id}`,
method: 'GET',
signal,
});
};
export const getGetLicenseQueryKey = ({ id }: GetLicensePathParameters) => {
return [`/api/v4/licenses/${id}`] as const;
};
export const getGetLicenseQueryOptions = <
TData = Awaited<ReturnType<typeof getLicense>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetLicensePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getLicense>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetLicenseQueryKey({ id });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getLicense>>> = ({
signal,
}) => getLicense({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<Awaited<ReturnType<typeof getLicense>>, TError, TData> & {
queryKey: QueryKey;
};
};
export type GetLicenseQueryResult = NonNullable<
Awaited<ReturnType<typeof getLicense>>
>;
export type GetLicenseQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get a license.
*/
export function useGetLicense<
TData = Awaited<ReturnType<typeof getLicense>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetLicensePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getLicense>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetLicenseQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get a license.
*/
export const invalidateGetLicense = async (
queryClient: QueryClient,
{ id }: GetLicensePathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetLicenseQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint refreshes the active license of the organization from the upstream server.
* @summary Refresh a license.
*/
export const refreshLicense = (
{ id }: RefreshLicensePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v4/licenses/${id}`,
method: 'PUT',
signal,
});
};
export const getRefreshLicenseMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof refreshLicense>>,
TError,
{ pathParams: RefreshLicensePathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof refreshLicense>>,
TError,
{ pathParams: RefreshLicensePathParameters },
TContext
> => {
const mutationKey = ['refreshLicense'];
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 refreshLicense>>,
{ pathParams: RefreshLicensePathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return refreshLicense(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type RefreshLicenseMutationResult = NonNullable<
Awaited<ReturnType<typeof refreshLicense>>
>;
export type RefreshLicenseMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Refresh a license.
*/
export const useRefreshLicense = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof refreshLicense>>,
TError,
{ pathParams: RefreshLicensePathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof refreshLicense>>,
TError,
{ pathParams: RefreshLicensePathParameters },
TContext
> => {
return useMutation(getRefreshLicenseMutationOptions(options));
};
/**
* This endpoint gets the active license of the organization.
* @summary Get the active license.
*/
export const getActiveLicense = (signal?: AbortSignal) => {
return GeneratedAPIInstance<GetActiveLicense200>({
url: `/api/v4/orgs/me/license`,
method: 'GET',
signal,
});
};
export const getGetActiveLicenseQueryKey = () => {
return [`/api/v4/orgs/me/license`] as const;
};
export const getGetActiveLicenseQueryOptions = <
TData = Awaited<ReturnType<typeof getActiveLicense>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getActiveLicense>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetActiveLicenseQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getActiveLicense>>> = ({
signal,
}) => getActiveLicense(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getActiveLicense>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetActiveLicenseQueryResult = NonNullable<
Awaited<ReturnType<typeof getActiveLicense>>
>;
export type GetActiveLicenseQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get the active license.
*/
export function useGetActiveLicense<
TData = Awaited<ReturnType<typeof getActiveLicense>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getActiveLicense>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetActiveLicenseQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get the active license.
*/
export const invalidateGetActiveLicense = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetActiveLicenseQueryKey() },
options,
);
return queryClient;
};

View File

@@ -2175,6 +2175,7 @@ export enum CoretypesKindDTO {
'factor-api-key' = 'factor-api-key',
license = 'license',
subscription = 'subscription',
'deployment-host' = 'deployment-host',
logs = 'logs',
traces = 'traces',
metrics = 'metrics',
@@ -7172,6 +7173,197 @@ export interface InframonitoringtypesVolumesDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface LicensetypesFeatureDTO {
/**
* @type boolean
*/
active?: boolean;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
route?: string;
/**
* @type integer
* @format int64
*/
usage?: number;
/**
* @type integer
* @format int64
*/
usage_limit?: number;
}
export interface LicensetypesLicenseEventQueueDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
/**
* @type string
*/
event: string;
/**
* @type string
* @format date-time
*/
scheduledAt: string;
/**
* @type string
*/
status: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
}
export interface LicensetypesLicensePlanDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
/**
* @type string
*/
description: string;
/**
* @type string
*/
id: string;
/**
* @type boolean
*/
isActive: boolean;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
}
export interface LicensetypesGettableLicenseDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
eventQueue: LicensetypesLicenseEventQueueDTO;
/**
* @type array,null
*/
features: LicensetypesFeatureDTO[] | null;
/**
* @type string
* @format date-time
*/
freeUntil: string;
/**
* @type string
*/
id: string;
plan: LicensetypesLicensePlanDTO;
/**
* @type string
*/
platform: string;
/**
* @type string
*/
state: string;
/**
* @type string
*/
status: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
/**
* @type integer
* @format int64
*/
validFrom: number;
/**
* @type integer
* @format int64
*/
validUntil: number;
}
export interface LicensetypesGettableLicenseWithKeyDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
eventQueue: LicensetypesLicenseEventQueueDTO;
/**
* @type array,null
*/
features: LicensetypesFeatureDTO[] | null;
/**
* @type string
* @format date-time
*/
freeUntil: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
key: string;
plan: LicensetypesLicensePlanDTO;
/**
* @type string
*/
platform: string;
/**
* @type string
*/
state: string;
/**
* @type string
*/
status: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
/**
* @type integer
* @format int64
*/
validFrom: number;
/**
* @type integer
* @format int64
*/
validUntil: number;
}
export interface LicensetypesPostableLicenseDTO {
/**
* @type string
*/
key?: string;
}
/**
* @nullable
*/
@@ -10248,11 +10440,6 @@ export type GetAIObservabilityFieldsValuesParams = {
* @description undefined
*/
name?: string;
/**
* @type string
* @description undefined
*/
existingQuery?: string;
};
export type GetAIObservabilityFieldsValues200 = {
@@ -12433,6 +12620,42 @@ export type GetFlamegraph200 = {
status: string;
};
export type ListLicenses200 = {
/**
* @type array
*/
data: LicensetypesGettableLicenseDTO[];
/**
* @type string
*/
status: string;
};
export type DeleteLicensePathParameters = {
id: string;
};
export type GetLicensePathParameters = {
id: string;
};
export type GetLicense200 = {
data: LicensetypesGettableLicenseWithKeyDTO;
/**
* @type string
*/
status: string;
};
export type RefreshLicensePathParameters = {
id: string;
};
export type GetActiveLicense200 = {
data: LicensetypesGettableLicenseDTO;
/**
* @type string
*/
status: string;
};
export type GetWaterfallV4PathParameters = {
traceID: string;
};

View File

@@ -7,9 +7,8 @@ import axios from 'axios';
import TextToolTip from 'components/TextToolTip';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { useOptionsMenu } from 'container/OptionsMenu';
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useDeleteView } from 'hooks/saveViews/useDeleteView';
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
@@ -69,9 +68,7 @@ function ExplorerCard({
setIsOpen(newOpen);
};
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
const viewKey = useGetSearchQueryParam(QueryParams.viewKey) || '';
const { viewName, viewKey } = useGetSavedViewParams();
const { options } = useOptionsMenu({
storageKey:

View File

@@ -1,5 +1,3 @@
import { QueryParams } from 'constants/query';
export const ExploreHeaderToolTip = {
url: 'https://signoz.io/docs/querying/overview/?utm_source=product&utm_medium=new-query-builder',
text: 'More details on how to use query builder',
@@ -9,5 +7,3 @@ export const SaveButtonText = {
SAVE_AS_NEW_VIEW: 'Save as new view',
SAVE_VIEW: 'Save view',
};
export type QuerySearchParamNames = QueryParams.viewName | QueryParams.viewKey;

View File

@@ -241,28 +241,29 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
))
)}
{!showOnlyWhereClause && currentQuery.builder.queryFormulas.length > 0 && (
<div className="qb-formulas-container">
{currentQuery.builder.queryFormulas.map((formula, index) => {
const query =
currentQuery.builder.queryData[index] ||
currentQuery.builder.queryData[0];
{!showOnlyWhereClause &&
currentQuery.builder.queryFormulas?.length > 0 && (
<div className="qb-formulas-container">
{currentQuery.builder.queryFormulas.map((formula, index) => {
const query =
currentQuery.builder.queryData[index] ||
currentQuery.builder.queryData[0];
return (
<div key={formula.queryName} className="qb-formula">
<Formula
filterConfigs={filterConfigs}
query={query}
formula={formula}
index={index}
isAdditionalFilterEnable={false}
isQBV2
/>
</div>
);
})}
</div>
)}
return (
<div key={formula.queryName} className="qb-formula">
<Formula
filterConfigs={filterConfigs}
query={query}
formula={formula}
index={index}
isAdditionalFilterEnable={false}
isQBV2
/>
</div>
);
})}
</div>
)}
{shouldShowFooter && (
<QueryFooter
@@ -290,7 +291,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
</div>
))}
{currentQuery.builder.queryFormulas.map((formula) => (
{currentQuery.builder.queryFormulas?.map((formula) => (
<div key={formula.queryName} className="formula-name">
{formula.queryName}
</div>

View File

@@ -212,6 +212,32 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
expect(handleRunQueryMock).toHaveBeenCalled();
});
it('does not crash when builder.queryFormulas/queryTraceOperator are missing (partial/legacy query)', () => {
const currentQueryBase = baseQBContext.currentQuery as Query;
mockedUseQueryBuilder.mockReturnValue({
...baseQBContext,
currentQuery: {
...currentQueryBase,
builder: {
queryData: currentQueryBase.builder.queryData,
queryFormulas: undefined as unknown as [],
queryTraceOperator: undefined as unknown as [],
},
},
});
expect(() =>
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />),
).not.toThrow();
// query list still renders from queryData, formulas block is skipped
expect(document.querySelector('.query-names-section')).toBeInTheDocument();
expect(
document.querySelector('.qb-formulas-container'),
).not.toBeInTheDocument();
});
it('fx button is disabled when functions already exist', () => {
const currentQueryBase = baseQBContext.currentQuery as Query;
const supersetQueryBase = baseQBContext.supersetQuery as Query;

View File

@@ -4,7 +4,7 @@ import {
} from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { render, screen, waitFor } from 'tests/test-utils';
import {
setupAuthzAdmin,
setupAuthzDeny,
@@ -110,10 +110,7 @@ describe('ServiceAccountDrawer — permissions', () => {
it('shows PermissionDeniedCallout in Keys tab when list-keys permission is denied', async () => {
server.use(setupAuthzDeny(APIKeyListPermission));
renderDrawer();
await screen.findByDisplayValue('CI Bot');
fireEvent.click(screen.getByRole('radio', { name: /keys/i }));
renderDrawer({ account: 'sa-1', tab: 'keys' });
await waitFor(() => {
expect(screen.getByText(/list:factor-api-key/)).toBeInTheDocument();

View File

@@ -54,7 +54,7 @@ import {
} from 'container/OptionsMenu/constants';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
import { useSaveView } from 'hooks/saveViews/useSaveView';
@@ -287,8 +287,7 @@ function ExplorerOptions({
const compositeQuery = mapCompositeQueryFromQuery(currentQuery, panelType);
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
const viewKey = useGetSearchQueryParam(QueryParams.viewKey) || '';
const { viewName, viewKey } = useGetSavedViewParams();
const extraData = viewsData?.data?.data?.find(
(view) => view.id === viewKey,

View File

@@ -53,17 +53,24 @@ export const getUpdatedStepInterval = (evalWindow?: string): number => {
};
export const getSelectedQueryOptions = (
queries: Array<
| IBuilderQuery
| IBuilderTraceOperator
| IBuilderFormula
| IClickHouseQuery
| IPromQLQuery
>,
): SelectProps['options'] =>
queries
queries:
| Array<
| IBuilderQuery
| IBuilderTraceOperator
| IBuilderFormula
| IClickHouseQuery
| IPromQLQuery
>
| undefined
| null,
): SelectProps['options'] => {
if (!queries) {
return [];
}
return queries
.filter((query) => !query.disabled)
.map((query) => ({
label: 'queryName' in query ? query.queryName : query.name,
value: 'queryName' in query ? query.queryName : query.name,
}));
};

View File

@@ -15,9 +15,8 @@ import {
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { FeatureKeys } from 'constants/features';
import { QueryParams } from 'constants/query';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
import { useNotifications } from 'hooks/useNotifications';
@@ -50,7 +49,7 @@ function BodyTitleRenderer({
const { featureFlags } = useAppContext();
const [, setCopy] = useCopyToClipboard();
const { notifications } = useNotifications();
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
const { viewName } = useGetSavedViewParams();
const cleanedNodeKey = removeObjectFromString(nodeKey);
const isBodyJsonQueryEnabled =

View File

@@ -7,13 +7,12 @@ import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
import cx from 'classnames';
import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { QueryParams } from 'constants/query';
import { OPERATORS } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
import {
@@ -141,7 +140,7 @@ export default function TableViewActions(
const { pathname } = useLocation();
const { stagedQuery, updateQueriesData } = useQueryBuilder();
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
const { viewName } = useGetSavedViewParams();
const { dataType, logType: fieldType } = getFieldAttributes(record.field);
// there is no option for where clause in old logs explorer and live logs page or infra monitoring

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
@@ -88,7 +88,7 @@ jest.mock('react-router-dom', () => ({
}));
jest.mock('hooks/queryBuilder/useQueryBuilder');
jest.mock('hooks/queryBuilder/useGetSearchQueryParam');
jest.mock('hooks/saveViews/useGetSavedViewParams');
describe('TableViewActions', () => {
const TEST_VALUE = 'test value';
@@ -140,8 +140,10 @@ describe('TableViewActions', () => {
}),
} as any);
// Default mock for useGetSearchQueryParam
jest.mocked(useGetSearchQueryParam).mockReturnValue(null);
// Default mock for useGetSavedViewParams
jest
.mocked(useGetSavedViewParams)
.mockReturnValue({ viewName: '', viewKey: '' });
});
it('should render without crashing', () => {
@@ -249,7 +251,9 @@ describe('TableViewActions', () => {
updateQueriesData: mockUpdateQueriesData,
} as any);
jest.mocked(useGetSearchQueryParam).mockReturnValue(null);
jest
.mocked(useGetSavedViewParams)
.mockReturnValue({ viewName: '', viewKey: '' });
render(
<TableViewActions

View File

@@ -3,10 +3,9 @@ import { useLocation } from 'react-router-dom';
import { CircleMinus, CirclePlus, Layers, RefreshCw } from '@signozhq/icons';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { FeatureKeys } from 'constants/features';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
@@ -58,7 +57,7 @@ export function useLogAttributeActions({
const { pathname } = useLocation();
const { stagedQuery, updateQueriesData } = useQueryBuilder();
const { featureFlags } = useAppContext();
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
const { viewName } = useGetSavedViewParams();
const isBodyJsonQueryEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)

View File

@@ -27,6 +27,14 @@ export const useGetCompositeQueryParam = (): Query | null => {
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
);
// Add default values for optional fields if empty
if (parsedCompositeQuery?.builder) {
parsedCompositeQuery.builder.queryFormulas =
parsedCompositeQuery.builder.queryFormulas ?? [];
parsedCompositeQuery.builder.queryTraceOperator =
parsedCompositeQuery.builder.queryTraceOperator ?? [];
}
// Convert old format to new format for each query in builder.queryData
if (parsedCompositeQuery?.builder?.queryData) {
parsedCompositeQuery.builder.queryData =

View File

@@ -1,15 +0,0 @@
import { useMemo } from 'react';
import { QuerySearchParamNames } from 'components/ExplorerCard/constants';
import useUrlQuery from 'hooks/useUrlQuery';
export const useGetSearchQueryParam = (
searchParams: QuerySearchParamNames,
): string | null => {
const urlQuery = useUrlQuery();
return useMemo(() => {
const searchQuery = urlQuery.get(searchParams);
return searchQuery ? JSON.parse(searchQuery) : null;
}, [urlQuery, searchParams]);
};

View File

@@ -0,0 +1,60 @@
import { renderHook } from '@testing-library/react';
import useUrlQuery from 'hooks/useUrlQuery';
import { useGetSavedViewParams } from '../useGetSavedViewParams';
jest.mock('hooks/useUrlQuery');
const mockedUseUrlQuery = useUrlQuery as jest.Mock;
const setSearch = (search: string): void => {
mockedUseUrlQuery.mockReturnValue(new URLSearchParams(search));
};
describe('useGetSavedViewParams', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns empty strings when no params are present', () => {
setSearch('');
const { result } = renderHook(() => useGetSavedViewParams());
expect(result.current).toStrictEqual({ viewName: '', viewKey: '' });
});
it('parses JSON-stringified values', () => {
setSearch(
`viewName=${encodeURIComponent(
JSON.stringify('Hindsight'),
)}&viewKey=${encodeURIComponent(JSON.stringify('abc-123'))}`,
);
const { result } = renderHook(() => useGetSavedViewParams());
expect(result.current).toStrictEqual({
viewName: 'Hindsight',
viewKey: 'abc-123',
});
});
it('falls back to the raw string when a value is not valid JSON', () => {
setSearch('viewName=Hindsight&viewKey=some-uuid-value');
const { result } = renderHook(() => useGetSavedViewParams());
expect(result.current).toStrictEqual({
viewName: 'Hindsight',
viewKey: 'some-uuid-value',
});
});
it('does not throw and keeps the raw string for non-string JSON', () => {
setSearch('viewName=123');
const { result } = renderHook(() => useGetSavedViewParams());
expect(result.current).toStrictEqual({ viewName: '123', viewKey: '' });
});
});

View File

@@ -0,0 +1,33 @@
import { useMemo } from 'react';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
interface SavedViewParams {
viewName: string;
viewKey: string;
}
const parseViewParam = (value: string | null): string => {
if (!value) {
return '';
}
try {
const parsed = JSON.parse(value);
return typeof parsed === 'string' ? parsed : value;
} catch {
return value;
}
};
export const useGetSavedViewParams = (): SavedViewParams => {
const urlQuery = useUrlQuery();
return useMemo(
() => ({
viewName: parseViewParam(urlQuery.get(QueryParams.viewName)),
viewKey: parseViewParam(urlQuery.get(QueryParams.viewKey)),
}),
[urlQuery],
);
};

View File

@@ -6,7 +6,7 @@ import { SIGNOZ_VALUE } from 'container/QueryBuilder/filters/OrderByFilter/const
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { useGetSearchQueryParam } from './queryBuilder/useGetSearchQueryParam';
import { useGetSavedViewParams } from './saveViews/useGetSavedViewParams';
import { useQueryBuilder } from './queryBuilder/useQueryBuilder';
export interface ICurrentQueryData {
@@ -31,9 +31,7 @@ export const useHandleExplorerTabChange = (): {
updateQueriesData,
} = useQueryBuilder();
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
const viewKey = useGetSearchQueryParam(QueryParams.viewKey) || '';
const { viewName, viewKey } = useGetSavedViewParams();
const getUpdateQuery = useCallback(
(newPanelType: PANEL_TYPES): Query => {

View File

@@ -163,20 +163,23 @@ export function QueryBuilderProvider({
const prepareQueryBuilderData = useCallback(
(query: Query): Query => {
const builder: QueryBuilderData = {
queryData: query.builder.queryData?.map((item) => ({
...initialQueryBuilderFormValuesMap[
initialDataSource || DataSource.METRICS
],
...item,
})),
queryFormulas: query.builder.queryFormulas?.map((item) => ({
...initialFormulaBuilderFormValues,
...item,
})),
queryTraceOperator: query.builder.queryTraceOperator?.map((item) => ({
...initialQueryBuilderFormTraceOperatorValues,
...item,
})),
queryData:
query.builder.queryData?.map((item) => ({
...initialQueryBuilderFormValuesMap[
initialDataSource || DataSource.METRICS
],
...item,
})) ?? [],
queryFormulas:
query.builder.queryFormulas?.map((item) => ({
...initialFormulaBuilderFormValues,
...item,
})) ?? [],
queryTraceOperator:
query.builder.queryTraceOperator?.map((item) => ({
...initialQueryBuilderFormTraceOperatorValues,
...item,
})) ?? [],
};
const setupedQueryData = builder.queryData.map((item) => {
@@ -209,15 +212,17 @@ export function QueryBuilderProvider({
return currentElement;
});
const promql: IPromQLQuery[] = query.promql.map((item) => ({
...initialQueryPromQLData,
...item,
}));
const promql: IPromQLQuery[] =
query.promql?.map((item) => ({
...initialQueryPromQLData,
...item,
})) ?? [];
const clickHouse: IClickHouseQuery[] = query.clickhouse_sql.map((item) => ({
...initialClickHouseData,
...item,
}));
const clickHouse: IClickHouseQuery[] =
query.clickhouse_sql?.map((item) => ({
...initialClickHouseData,
...item,
})) ?? [];
const newQueryState: QueryState = {
clickhouse_sql: clickHouse,

View File

@@ -66,6 +66,7 @@
"factor-api-key",
"license",
"subscription",
"deployment-host",
"logs",
"traces",
"metrics",

View File

@@ -0,0 +1,165 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/licensetypes"
"github.com/gorilla/mux"
)
func (provider *provider) addLicensingRoutes(router *mux.Router) error {
if err := router.Handle("/api/v4/licenses", handler.New(
provider.authzMiddleware.CheckResources(provider.licensingHandler.Create, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "ActivateLicense",
Tags: []string{"licenses"},
Summary: "Activate a license.",
Description: "This endpoint validates the license key with the upstream server and activates the license for the organization.",
Request: new(licensetypes.PostableLicense),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusAccepted,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceLicense.Scope(coretypes.VerbCreate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceLicense,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v4/licenses", handler.New(
provider.authzMiddleware.CheckResources(provider.licensingHandler.List, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "ListLicenses",
Tags: []string{"licenses"},
Summary: "List licenses.",
Description: "This endpoint lists all the licenses of the organization.",
Request: nil,
RequestContentType: "",
Response: make([]*licensetypes.GettableLicense, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceLicense.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceLicense,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v4/orgs/me/license", handler.New(provider.authzMiddleware.OpenAccess(provider.licensingHandler.GetActive), handler.OpenAPIDef{
ID: "GetActiveLicense",
Tags: []string{"licenses"},
Summary: "Get the active license.",
Description: "This endpoint gets the active license of the organization.",
Request: nil,
RequestContentType: "",
Response: new(licensetypes.GettableLicense),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusNotImplemented},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes(nil),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v4/licenses/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.licensingHandler.Get, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetLicense",
Tags: []string{"licenses"},
Summary: "Get a license.",
Description: "This endpoint gets the license by id.",
Request: nil,
RequestContentType: "",
Response: new(licensetypes.GettableLicenseWithKey),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceLicense.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceLicense,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v4/licenses/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.licensingHandler.Refresh, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "RefreshLicense",
Tags: []string{"licenses"},
Summary: "Refresh a license.",
Description: "This endpoint refreshes the active license of the organization from the upstream server.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceLicense.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceLicense,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v4/licenses/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.licensingHandler.Delete, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteLicense",
Tags: []string{"licenses"},
Summary: "Delete a license.",
Description: "This endpoint deletes the license by id. Licenses managed by SigNoz Cloud cannot be deleted.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceLicense.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceLicense,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/aiobservability"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
@@ -67,6 +68,7 @@ type provider struct {
authzHandler authz.Handler
rawDataExportHandler rawdataexport.Handler
zeusHandler zeus.Handler
licensingHandler licensing.Handler
querierHandler querier.Handler
serviceAccountHandler serviceaccount.Handler
serviceAccountGetter serviceaccount.Getter
@@ -105,6 +107,7 @@ func NewFactory(
authzHandler authz.Handler,
rawDataExportHandler rawdataexport.Handler,
zeusHandler zeus.Handler,
licensingHandler licensing.Handler,
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
serviceAccountGetter serviceaccount.Getter,
@@ -146,6 +149,7 @@ func NewFactory(
authzHandler,
rawDataExportHandler,
zeusHandler,
licensingHandler,
querierHandler,
serviceAccountHandler,
serviceAccountGetter,
@@ -189,6 +193,7 @@ func newProvider(
authzHandler authz.Handler,
rawDataExportHandler rawdataexport.Handler,
zeusHandler zeus.Handler,
licensingHandler licensing.Handler,
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
serviceAccountGetter serviceaccount.Getter,
@@ -231,6 +236,7 @@ func newProvider(
authzHandler: authzHandler,
rawDataExportHandler: rawDataExportHandler,
zeusHandler: zeusHandler,
licensingHandler: licensingHandler,
querierHandler: querierHandler,
serviceAccountHandler: serviceAccountHandler,
serviceAccountGetter: serviceAccountGetter,
@@ -332,6 +338,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addLicensingRoutes(router); err != nil {
return err
}
if err := provider.addZeusRoutes(router); err != nil {
return err
}

View File

@@ -5,6 +5,8 @@ import (
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/zeustypes"
"github.com/gorilla/mux"
)
@@ -27,7 +29,7 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authzMiddleware.ViewAccess(provider.zeusHandler.GetHosts), handler.OpenAPIDef{
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authzMiddleware.CheckResources(provider.zeusHandler.GetHosts, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName), handler.OpenAPIDef{
ID: "GetHosts",
Tags: []string{"zeus"},
Summary: "Get host info from Zeus.",
@@ -39,12 +41,17 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDeploymentHost.Scope(coretypes.VerbList)}),
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDeploymentHost,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authzMiddleware.AdminAccess(provider.zeusHandler.PutHost), handler.OpenAPIDef{
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authzMiddleware.CheckResources(provider.zeusHandler.PutHost, authtypes.SigNozAdminRoleName), handler.OpenAPIDef{
ID: "PutHost",
Tags: []string{"zeus"},
Summary: "Put host in Zeus for a deployment.",
@@ -56,8 +63,14 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDeploymentHost.Scope(coretypes.VerbUpdate)}),
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDeploymentHost,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.BodyJSONPath("name"),
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodPut).GetError(); err != nil {
return err
}

169
pkg/licensing/handler.go Normal file
View File

@@ -0,0 +1,169 @@
package licensing
import (
"net/http"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/licensetypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
type handler struct {
licensing Licensing
}
func NewHandler(licensing Licensing) Handler {
return &handler{licensing: licensing}
}
func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
req := new(licensetypes.PostableLicense)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
err = handler.licensing.Activate(ctx, valuer.MustNewUUID(claims.OrgID), req.Key)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusAccepted, nil)
}
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
licenses, err := handler.licensing.List(ctx, valuer.MustNewUUID(claims.OrgID))
if err != nil {
render.Error(rw, err)
return
}
gettableLicenses := make([]*licensetypes.GettableLicense, 0, len(licenses))
for _, license := range licenses {
gettableLicenses = append(gettableLicenses, licensetypes.NewGettableLicense(license))
}
render.Success(rw, http.StatusOK, gettableLicenses)
}
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
licenseID, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
license, err := handler.licensing.Get(ctx, valuer.MustNewUUID(claims.OrgID), licenseID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, licensetypes.NewGettableLicenseWithKey(license))
}
func (handler *handler) Refresh(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
licenseID, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
activeLicense, err := handler.licensing.GetActive(ctx, orgID)
if err != nil {
render.Error(rw, err)
return
}
if activeLicense.ID != licenseID {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only the active license %s can be refreshed", activeLicense.ID.StringValue()))
return
}
if err := handler.licensing.Refresh(ctx, orgID); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) Delete(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
licenseID, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
if err := handler.licensing.Delete(ctx, valuer.MustNewUUID(claims.OrgID), licenseID); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) GetActive(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
license, err := handler.licensing.GetActive(ctx, valuer.MustNewUUID(claims.OrgID))
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, licensetypes.NewGettableLicense(license))
}

View File

@@ -21,10 +21,16 @@ type Licensing interface {
// Validate validates the license with the upstream server
Validate(ctx context.Context) error
// Activate validates and enables the license
// Activate validates the key with the upstream server and enables the license
Activate(ctx context.Context, organizationID valuer.UUID, key string) error
// GetActive fetches the current active license in org
GetActive(ctx context.Context, organizationID valuer.UUID) (*licensetypes.License, error)
// Get fetches the license by id in org
Get(ctx context.Context, organizationID valuer.UUID, licenseID valuer.UUID) (*licensetypes.License, error)
// List fetches all the licenses in org
List(ctx context.Context, organizationID valuer.UUID) ([]*licensetypes.License, error)
// Delete deletes the license by id in org, cloud licenses cannot be deleted
Delete(ctx context.Context, organizationID valuer.UUID, licenseID valuer.UUID) error
// Refresh refreshes the license state from upstream server
Refresh(ctx context.Context, organizationID valuer.UUID) error
// Checkout creates a checkout session via upstream server and returns the redirection link
@@ -38,10 +44,20 @@ type Licensing interface {
}
type API interface {
Activate(http.ResponseWriter, *http.Request)
Refresh(http.ResponseWriter, *http.Request)
GetActive(http.ResponseWriter, *http.Request)
Checkout(http.ResponseWriter, *http.Request)
Portal(http.ResponseWriter, *http.Request)
}
type Handler interface {
Create(http.ResponseWriter, *http.Request)
List(http.ResponseWriter, *http.Request)
Get(http.ResponseWriter, *http.Request)
Refresh(http.ResponseWriter, *http.Request)
Delete(http.ResponseWriter, *http.Request)
GetActive(http.ResponseWriter, *http.Request)
}

View File

@@ -14,18 +14,6 @@ func NewLicenseAPI() licensing.API {
return &noopLicensingAPI{}
}
func (api *noopLicensingAPI) Activate(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "not implemented"))
}
func (api *noopLicensingAPI) GetActive(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "not implemented"))
}
func (api *noopLicensingAPI) Refresh(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "not implemented"))
}
func (api *noopLicensingAPI) Checkout(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "not implemented"))
}

View File

@@ -39,6 +39,18 @@ func (provider *noopLicensing) Activate(ctx context.Context, organizationID valu
return errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "fetching license is not supported")
}
func (provider *noopLicensing) Get(ctx context.Context, organizationID valuer.UUID, licenseID valuer.UUID) (*licensetypes.License, error) {
return nil, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "fetching license is not supported")
}
func (provider *noopLicensing) List(ctx context.Context, organizationID valuer.UUID) ([]*licensetypes.License, error) {
return nil, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "listing licenses is not supported")
}
func (provider *noopLicensing) Delete(ctx context.Context, organizationID valuer.UUID, licenseID valuer.UUID) error {
return errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "deleting license is not supported")
}
func (provider *noopLicensing) Validate(ctx context.Context) error {
return errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "validating license is not supported")
}

View File

@@ -5,6 +5,7 @@ import (
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/aiobservability"
@@ -65,6 +66,13 @@ func (handler *handler) GetFieldsValues(rw http.ResponseWriter, req *http.Reques
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
defer cancel()
// binding ignores query params the struct does not declare, so an unsupported
// existingQuery would silently return values it did not narrow
if req.URL.Query().Has("existingQuery") {
render.Error(rw, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "existingQuery is not supported"))
return
}
var params aiobservabilitytypes.PostableFieldValueParams
if err := binding.Query.BindQuery(req.URL.Query(), &params); err != nil {
render.Error(rw, err)
@@ -76,29 +84,18 @@ func (handler *handler) GetFieldsValues(rw http.ResponseWriter, req *http.Reques
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
params.ExistingQuery = aitelemetryschema.ScopedExistingQuery(params.ExistingQuery)
fieldValueSelector := aiobservabilitytypes.NewFieldValueSelectorFromPostableFieldValueParams(params)
values := &telemetrytypes.TelemetryFieldValues{}
complete := true
// the trace context names the computed per-trace aggregates, which are never ingested
if fieldValueSelector.FieldContext != telemetrytypes.FieldContextTrace {
values, complete, err = handler.telemetryMetadataStore.GetAllValues(ctx, orgID, fieldValueSelector)
values, complete, err = handler.telemetryMetadataStore.GetAllValues(ctx, valuer.MustNewUUID(claims.OrgID), fieldValueSelector)
if err != nil {
render.Error(rw, err)
return
}
// related values are best-effort: on failure the plain values still serve
// the filter bar
relatedValues, relatedComplete, err := handler.telemetryMetadataStore.GetRelatedValues(ctx, orgID, fieldValueSelector)
if err != nil {
relatedValues = []string{}
}
values.RelatedValues = relatedValues
complete = complete && relatedComplete
}
render.Success(rw, http.StatusOK, &telemetrytypes.GettableFieldValues{

View File

@@ -2,6 +2,7 @@ package prometheus
import (
"log/slog"
"time"
"github.com/prometheus/prometheus/promql"
)
@@ -23,5 +24,11 @@ func NewEngine(logger *slog.Logger, cfg Config) *Engine {
Timeout: cfg.Timeout,
ActiveQueryTracker: activeQueryTracker,
LookbackDelta: cfg.LookbackDelta,
// The engine calls this for subqueries that do not set a step, such as
// `metric[5m:]`, and segfaults if it is nil. 1m matches the default
// global evaluation_interval that Prometheus wires here.
NoStepSubqueryIntervalFn: func(int64) int64 {
return time.Minute.Milliseconds()
},
})
}

View File

@@ -0,0 +1,33 @@
package prometheus
import (
"context"
"log/slog"
"testing"
"time"
"github.com/prometheus/prometheus/storage"
"github.com/stretchr/testify/require"
)
func TestNoStepSubqueryDoesNotPanic(t *testing.T) {
engine := NewEngine(slog.New(slog.DiscardHandler), Config{Timeout: time.Minute})
queryable := storage.QueryableFunc(func(int64, int64) (storage.Querier, error) {
return storage.NoopQuerier(), nil
})
qry, err := engine.NewRangeQuery(
context.Background(),
queryable,
nil,
"max_over_time(some_metric[5m:])",
time.Now().Add(-time.Hour),
time.Now(),
time.Minute,
)
require.NoError(t, err)
defer qry.Close()
res := qry.Exec(context.Background())
require.NoError(t, res.Err)
}

View File

@@ -457,13 +457,6 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/register", am.OpenAccess(aH.registerUser)).Methods(http.MethodPost)
router.HandleFunc("/api/v3/licenses", am.ViewAccess(func(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, []any{})
})).Methods(http.MethodGet)
router.HandleFunc("/api/v3/licenses/active", am.ViewAccess(func(rw http.ResponseWriter, req *http.Request) {
aH.LicensingAPI.Activate(rw, req)
})).Methods(http.MethodGet)
router.HandleFunc("/api/v1/span_percentile", am.ViewAccess(aH.Signoz.Handlers.SpanPercentile.GetSpanPercentileDetails)).Methods(http.MethodPost)
// Query Filter Analyzer api used to extract metric names and grouping columns from a query

View File

@@ -77,6 +77,7 @@ type Handlers struct {
AIObservability aiobservability.Handler
AuthzHandler authz.Handler
ZeusHandler zeus.Handler
LicensingHandler licensing.Handler
QuerierHandler querier.Handler
ServiceAccountHandler serviceaccount.Handler
RegistryHandler factory.Handler
@@ -95,7 +96,7 @@ func NewHandlers(
providerSettings factory.ProviderSettings,
analytics analytics.Analytics,
querierHandler querier.Handler,
licensing licensing.Licensing,
licensingService licensing.Licensing,
global global.Global,
flaggerService flagger.Flagger,
gatewayService gateway.Gateway,
@@ -125,7 +126,8 @@ func NewHandlers(
Fields: implfields.NewHandler(providerSettings, telemetryMetadataStore),
AIObservability: implaiobservability.NewHandler(telemetryMetadataStore),
AuthzHandler: signozauthzapi.NewHandler(authz),
ZeusHandler: zeus.NewHandler(zeusService, licensing),
ZeusHandler: zeus.NewHandler(zeusService, licensingService),
LicensingHandler: licensing.NewHandler(licensingService),
QuerierHandler: querierHandler,
ServiceAccountHandler: implserviceaccount.NewHandler(modules.ServiceAccount, modules.ServiceAccountGetter),
RegistryHandler: registryHandler,

View File

@@ -17,6 +17,7 @@ import (
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/aiobservability"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
@@ -80,6 +81,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ authz.Handler }{},
struct{ rawdataexport.Handler }{},
struct{ zeus.Handler }{},
struct{ licensing.Handler }{},
struct{ querier.Handler }{},
struct{ serviceaccount.Handler }{},
struct{ serviceaccount.Getter }{},

View File

@@ -244,6 +244,8 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewDeleteOrphanUserRolesFactory(),
sqlmigration.NewMigrateLambdaDashboardsFactory(),
sqlmigration.NewAddAuthDomainTuplesFactory(sqlstore),
sqlmigration.NewAddDeploymentHostTuplesFactory(sqlstore),
sqlmigration.NewAddLicenseTuplesFactory(sqlstore),
)
}
@@ -334,6 +336,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.AuthzHandler,
handlers.RawDataExport,
handlers.ZeusHandler,
handlers.LicensingHandler,
handlers.QuerierHandler,
handlers.ServiceAccountHandler,
modules.ServiceAccountGetter,

View File

@@ -0,0 +1,161 @@
package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addDeploymentHostTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddDeploymentHostTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_deployment_host_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addDeploymentHostTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addDeploymentHostTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addDeploymentHostTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
// zeus hosts moved from the legacy ViewAccess/AdminAccess role gates to
// CheckResources, which on enterprise requires real tuples -- existing orgs
// never had these written, only new orgs get them from the registry at bootstrap.
tuples := []migrationTuple{
{authtypes.SigNozAdminRoleName, "metaresource", "deployment-host", "list"},
{authtypes.SigNozAdminRoleName, "metaresource", "deployment-host", "update"},
{authtypes.SigNozEditorRoleName, "metaresource", "deployment-host", "list"},
{authtypes.SigNozViewerRoleName, "metaresource", "deployment-host", "list"},
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions))
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions))
if err != nil {
return err
}
managedRoleGroups[roleName] = string(data)
}
for _, orgID := range orgIDs {
for roleName, data := range managedRoleGroups {
if _, err := tx.NewUpdate().
Model(new(roles)).
Set("transaction_groups = ?", data).
Where("org_id = ?", orgID).
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
Where("name = ?", roleName).
Exec(ctx); err != nil {
return err
}
}
}
return tx.Commit()
}
func (migration *addDeploymentHostTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,159 @@
package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addLicenseTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddLicenseTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_license_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addLicenseTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addLicenseTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addLicenseTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
tuples := []migrationTuple{
{authtypes.SigNozAdminRoleName, "metaresource", "license", "create"},
{authtypes.SigNozAdminRoleName, "metaresource", "license", "read"},
{authtypes.SigNozAdminRoleName, "metaresource", "license", "update"},
{authtypes.SigNozAdminRoleName, "metaresource", "license", "delete"},
{authtypes.SigNozAdminRoleName, "metaresource", "license", "list"},
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions))
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions))
if err != nil {
return err
}
managedRoleGroups[roleName] = string(data)
}
for _, orgID := range orgIDs {
for roleName, data := range managedRoleGroups {
if _, err := tx.NewUpdate().
Model(new(roles)).
Set("transaction_groups = ?", data).
Where("org_id = ?", orgID).
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
Where("name = ?", roleName).
Exec(ctx); err != nil {
return err
}
}
}
return tx.Commit()
}
func (migration *addLicenseTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -1,6 +1,8 @@
package aistatementbuilder
import (
"strings"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/statementbuilder"
@@ -25,8 +27,11 @@ func NewFactory(
// Scope describes gen_ai for the scoped trace builder: an AI trace has >=1 gen_ai
// LLM, tool, or agent span, and its list adds AI/LLM per-trace metrics.
func Scope() scopedtraces.TraceScope {
gateKeys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(aiobservabilitytypes.GenAISpanGateKeys))
for _, name := range aiobservabilitytypes.GenAISpanGateKeys {
gateKeyNames := []string{aiobservabilitytypes.GenAIRequestModel, aiobservabilitytypes.GenAIToolName, aiobservabilitytypes.GenAIAgentName}
gateExprs := make([]string, 0, len(gateKeyNames))
gateKeys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(gateKeyNames))
for _, name := range gateKeyNames {
gateExprs = append(gateExprs, name+" EXISTS")
gateKeys = append(gateKeys, &telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: telemetrytypes.SignalTraces,
@@ -74,7 +79,7 @@ func Scope() scopedtraces.TraceScope {
}
return scopedtraces.TraceScope{
FilterExpression: aiobservabilitytypes.GenAISpanFilterExpression(),
FilterExpression: strings.Join(gateExprs, " OR "),
FieldKeys: gateKeys,
Columns: columns,
DefaultOrderAlias: "last_activity_time",

View File

@@ -1,36 +0,0 @@
package aitelemetryschema
import (
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
)
var (
traceAggregateNames = func() map[string]struct{} {
names := make(map[string]struct{}, len(TraceAggregateFields))
for name := range TraceAggregateFields {
names[name] = struct{}{}
}
return names
}()
genAISpanGate = "(" + aiobservabilitytypes.GenAISpanFilterExpression() + ")"
)
// ScopedExistingQuery narrows value suggestions to gen_ai spans: the caller's
// filter minus its per-trace aggregate atoms (never ingested, so nothing can
// narrow on them), ANDed with the gen_ai span gate. An unparseable filter is
// dropped, matching how the metadata store treats it downstream.
func ScopedExistingQuery(existingQuery string) string {
spanExpr := ""
if existingQuery != "" {
if expr, _, err := querybuilder.SplitFilterForAggregates(existingQuery, traceAggregateNames); err == nil {
spanExpr = expr
}
}
if spanExpr == "" {
return genAISpanGate
}
return genAISpanGate + " AND (" + spanExpr + ")"
}

View File

@@ -1,94 +0,0 @@
package aitelemetryschema
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestScopedExistingQuery(t *testing.T) {
gate := "(gen_ai.request.model EXISTS OR gen_ai.tool.name EXISTS OR gen_ai.agent.name EXISTS)"
testCases := []struct {
name string
existingQuery string
expected string
}{
{
name: "empty query returns the gate alone",
existingQuery: "",
expected: gate,
},
{
name: "span filter is preserved under the gate",
existingQuery: "service.name = 'checkout'",
expected: gate + " AND (service.name = 'checkout')",
},
{
name: "trace aggregate filter is stripped",
existingQuery: "llm_call_count > 5",
expected: gate,
},
{
name: "mixed filter keeps only the span part",
existingQuery: "llm_call_count > 5 AND gen_ai.request.model = 'gpt-4'",
expected: gate + " AND (gen_ai.request.model = 'gpt-4')",
},
{
name: "trace context filter is stripped",
existingQuery: "trace.total_tokens > 100 AND service.name = 'checkout'",
expected: gate + " AND (service.name = 'checkout')",
},
{
name: "unparseable filter is dropped",
existingQuery: "service.name = ",
expected: gate,
},
{
name: "multiple span conditions survive as one AND chain",
existingQuery: "service.name = 'checkout' AND gen_ai.request.model = 'gpt-4' AND llm_call_count > 5",
expected: gate + " AND (service.name = 'checkout' AND gen_ai.request.model = 'gpt-4')",
},
{
name: "span OR group is kept whole and parenthesized against the AND join",
existingQuery: "service.name = 'a' OR service.name = 'b'",
expected: gate + " AND ((service.name = 'a' OR service.name = 'b'))",
},
{
name: "parenthesized span OR group ANDed with an aggregate keeps only the group",
existingQuery: "(service.name = 'a' OR service.name = 'b') AND llm_call_count > 5",
expected: gate + " AND ((service.name = 'a' OR service.name = 'b'))",
},
{
name: "OR group of trace aggregates is stripped whole",
existingQuery: "llm_call_count > 5 OR total_tokens > 100",
expected: gate,
},
{
name: "OR mixing aggregate and span atoms drops the whole filter",
existingQuery: "llm_call_count > 5 OR service.name = 'checkout'",
expected: gate,
},
{
name: "parenthesized AND group is split, not routed whole",
existingQuery: "(llm_call_count > 5 AND service.name = 'checkout') AND gen_ai.request.model = 'gpt-4'",
expected: gate + " AND (service.name = 'checkout' AND gen_ai.request.model = 'gpt-4')",
},
{
name: "NOT over an aggregate group is stripped",
existingQuery: "NOT (llm_call_count > 5) AND service.name = 'checkout'",
expected: gate + " AND (service.name = 'checkout')",
},
{
name: "NOT over a span group is kept",
existingQuery: "NOT (service.name = 'checkout')",
expected: gate + " AND (NOT (service.name = 'checkout'))",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.expected, ScopedExistingQuery(testCase.existingQuery))
})
}
}

View File

@@ -15,12 +15,11 @@ type PostableFieldKeysParams struct {
Limit int `query:"limit"`
}
// existingQuery may reference the computed per-trace aggregates, which are
// never ingested; those filters are stripped before narrowing values.
// existingQuery is unsupported until the computed per-trace aggregates it may
// reference can be narrowed on.
type PostableFieldValueParams struct {
PostableFieldKeysParams
Name string `query:"name"`
ExistingQuery string `query:"existingQuery"`
Name string `query:"name"`
}
func NewFieldKeySelectorFromPostableFieldKeysParams(params PostableFieldKeysParams) *telemetrytypes.FieldKeySelector {
@@ -31,7 +30,6 @@ func NewFieldValueSelectorFromPostableFieldValueParams(params PostableFieldValue
return telemetrytypes.NewFieldValueSelectorFromPostableFieldValueParams(telemetrytypes.PostableFieldValueParams{
PostableFieldKeysParams: params.telemetryParams(),
Name: params.Name,
ExistingQuery: params.ExistingQuery,
})
}

View File

@@ -1,7 +1,5 @@
package aiobservabilitytypes
import "strings"
// OpenTelemetry gen_ai semantic-convention attribute keys. Single source of truth
// shared by the AI query builder and the LLM pricing pipeline.
const (
@@ -20,20 +18,6 @@ const (
GenAIOutputMessages = "gen_ai.output.messages"
)
// GenAISpanGateKeys mark a span as gen_ai: an LLM call, a tool call, or an
// agent span. A trace belongs to the AI explorer when any span carries one.
var GenAISpanGateKeys = []string{GenAIRequestModel, GenAIToolName, GenAIAgentName}
// GenAISpanFilterExpression renders the gate as a query-builder filter
// expression: each gate key ORed on EXISTS.
func GenAISpanFilterExpression() string {
exprs := make([]string, 0, len(GenAISpanGateKeys))
for _, key := range GenAISpanGateKeys {
exprs = append(exprs, key+" EXISTS")
}
return strings.Join(exprs, " OR ")
}
// Per-span costs the SigNoz LLM pricing processor attaches; not OTel semconv.
const (
SignozGenAICostInput = "_signoz.gen_ai.cost_input"

View File

@@ -71,6 +71,7 @@ func (name Kind) Enum() []any {
KindFactorAPIKey,
KindLicense,
KindSubscription,
KindDeploymentHost,
KindLogs,
KindTraces,
KindMetrics,

View File

@@ -31,6 +31,7 @@ var Kinds = []Kind{
KindFactorAPIKey,
KindLicense,
KindSubscription,
KindDeploymentHost,
KindLogs,
KindTraces,
KindMetrics,
@@ -71,6 +72,7 @@ var (
KindFactorAPIKey = MustNewKind("factor-api-key")
KindLicense = MustNewKind("license")
KindSubscription = MustNewKind("subscription")
KindDeploymentHost = MustNewKind("deployment-host")
KindLogs = MustNewKind("logs")
KindTraces = MustNewKind("traces")
KindMetrics = MustNewKind("metrics")

View File

@@ -64,11 +64,10 @@ var ManagedRoleToTransactions = map[string][]Transaction{
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindFactorPassword}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindFactorPassword}, WildCardSelectorString)},
// license — admin only.
// Uniform LCRUD shape; actual ee routes are POST /api/v3/licenses (create
// = Activate), PUT /api/v3/licenses (update = Refresh), GET
// /api/v3/licenses/active (read; currently exposed as ViewAccess on the
// route side). delete and list are placeholders for shape parity, no
// route serves them today.
// Uniform LCRUD shape served by /api/v4/licenses: create = Activate,
// update = Refresh, read = Get (includes the key), list, delete (non-cloud
// licenses only). GET /api/v4/orgs/me/license is OpenAccess, so the read
// grant is not enforced there.
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLicense}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLicense}, WildCardSelectorString)},
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLicense}, WildCardSelectorString)},
@@ -191,6 +190,9 @@ var ManagedRoleToTransactions = map[string][]Transaction{
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
// deployment-host — admin updates, viewer lists
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDeploymentHost}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDeploymentHost}, WildCardSelectorString)},
// user-preference — every authenticated user can read+update their own
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},
@@ -283,6 +285,8 @@ var ManagedRoleToTransactions = map[string][]Transaction{
// ttl-setting — read only (admin updates)
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
// deployment-host — list only (admin updates)
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDeploymentHost}, WildCardSelectorString)},
// user-preference — every authenticated user can read+update their own
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},
@@ -341,6 +345,8 @@ var ManagedRoleToTransactions = map[string][]Transaction{
// ttl-setting — read only
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
// deployment-host — list only
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDeploymentHost}, WildCardSelectorString)},
// user-preference — every authenticated user can read+update their own
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},

View File

@@ -31,6 +31,7 @@ var Resources = []Resource{
ResourceMetaResourceFactorAPIKey,
ResourceMetaResourceLicense,
ResourceMetaResourceSubscription,
ResourceMetaResourceDeploymentHost,
ResourceTelemetryResourceLogs,
ResourceTelemetryResourceTraces,
ResourceTelemetryResourceMetrics,
@@ -71,6 +72,7 @@ var (
ResourceMetaResourceFactorAPIKey = NewResourceMetaResource(KindFactorAPIKey, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete)
ResourceMetaResourceLicense = NewResourceMetaResource(KindLicense)
ResourceMetaResourceSubscription = NewResourceMetaResource(KindSubscription)
ResourceMetaResourceDeploymentHost = NewResourceMetaResource(KindDeploymentHost, VerbList, VerbUpdate)
ResourceTelemetryResourceLogs = NewResourceTelemetryResource(KindLogs)
ResourceTelemetryResourceTraces = NewResourceTelemetryResource(KindTraces)
ResourceTelemetryResourceMetrics = NewResourceTelemetryResource(KindMetrics)

View File

@@ -3,15 +3,19 @@ package licensetypes
import (
"context"
"encoding/json"
"reflect"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/zeustypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
var (
ErrCodeCloudLicenseOperationUnsupported = errors.MustNewCode("cloud_license_operation_unsupported")
)
type StorableLicense struct {
bun.BaseModel `bun:"table:license"`
@@ -28,10 +32,12 @@ type License struct {
ID valuer.UUID
Key string
Data map[string]interface{}
PlanName valuer.String
Plan LicensePlan
EventQueue LicenseEventQueue
Features []*Feature
Status valuer.String
State string
State valuer.String
Platform valuer.String
FreeUntil time.Time
ValidFrom int64
ValidUntil int64
@@ -41,28 +47,47 @@ type License struct {
OrganizationID valuer.UUID
}
type GettableLicense map[string]any
type LicensePlan struct {
ID valuer.UUID `json:"id" required:"true"`
Name valuer.String `json:"name" required:"true"`
Description string `json:"description" required:"true"`
IsActive bool `json:"isActive" required:"true"`
CreatedAt time.Time `json:"createdAt" required:"true"`
UpdatedAt time.Time `json:"updatedAt" required:"true"`
}
type LicenseEventQueue struct {
Event valuer.String `json:"event" required:"true"`
Status valuer.String `json:"status" required:"true"`
ScheduledAt time.Time `json:"scheduledAt" required:"true"`
CreatedAt time.Time `json:"createdAt" required:"true"`
UpdatedAt time.Time `json:"updatedAt" required:"true"`
}
type GettableLicense struct {
ID valuer.UUID `json:"id" required:"true"`
ValidFrom int64 `json:"validFrom" required:"true"`
ValidUntil int64 `json:"validUntil" required:"true"`
Status valuer.String `json:"status" required:"true"`
State valuer.String `json:"state" required:"true"`
Platform valuer.String `json:"platform" required:"true"`
FreeUntil time.Time `json:"freeUntil" required:"true"`
CreatedAt time.Time `json:"createdAt" required:"true"`
UpdatedAt time.Time `json:"updatedAt" required:"true"`
Plan LicensePlan `json:"plan" required:"true"`
Features []*Feature `json:"features" required:"true"`
EventQueue LicenseEventQueue `json:"eventQueue" required:"true"`
}
type GettableLicenseWithKey struct {
GettableLicense
Key string `json:"key" required:"true"`
}
type PostableLicense struct {
Key string `json:"key"`
}
func NewStorableLicense(ID valuer.UUID, key string, data map[string]any, createdAt, updatedAt, lastValidatedAt time.Time, organizationID valuer.UUID) *StorableLicense {
return &StorableLicense{
Identifiable: types.Identifiable{
ID: ID,
},
TimeAuditable: types.TimeAuditable{
CreatedAt: createdAt,
UpdatedAt: updatedAt,
},
Key: key,
Data: data,
LastValidatedAt: lastValidatedAt,
OrgID: organizationID,
}
}
func NewStorableLicenseFromLicense(license *License) *StorableLicense {
return &StorableLicense{
Identifiable: types.Identifiable{
@@ -106,263 +131,201 @@ func GetActiveLicenseFromStorableLicenses(storableLicenses []*StorableLicense, o
return activeLicense, nil
}
func extractKeyFromMapStringInterface[T any](data map[string]interface{}, key string) (T, error) {
var zeroValue T
if val, ok := data[key]; ok {
if value, ok := val.(T); ok {
return value, nil
}
return zeroValue, errors.NewInvalidInputf(errors.CodeInvalidInput, "%s key is not a valid %s", key, reflect.TypeOf(zeroValue))
}
return zeroValue, errors.NewInvalidInputf(errors.CodeInvalidInput, "%s key is missing", key)
}
func NewLicense(data []byte, organizationID valuer.UUID) (*License, error) {
licenseData := map[string]any{}
err := json.Unmarshal(data, &licenseData)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to unmarshal license data")
func NewLicense(zeusLicense *zeustypes.License, organizationID valuer.UUID) (*License, error) {
if zeusLicense.ID.IsZero() {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "license id is missing")
}
var features []*Feature
// extract id from data
licenseIDStr, err := extractKeyFromMapStringInterface[string](licenseData, "id")
if err != nil {
return nil, err
if zeusLicense.Key == "" {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "license key is missing")
}
licenseID, err := valuer.NewUUID(licenseIDStr)
if err != nil {
return nil, err
}
delete(licenseData, "id")
// extract key from data
licenseKey, err := extractKeyFromMapStringInterface[string](licenseData, "key")
if err != nil {
return nil, err
}
delete(licenseData, "key")
// extract status from data
statusStr, err := extractKeyFromMapStringInterface[string](licenseData, "status")
if err != nil {
return nil, err
}
status := valuer.NewString(statusStr)
planMap, err := extractKeyFromMapStringInterface[map[string]any](licenseData, "plan")
planName, status, err := newPlanNameAndStatusFromZeusLicense(zeusLicense)
if err != nil {
return nil, err
}
planNameStr, err := extractKeyFromMapStringInterface[string](planMap, "name")
features := newMergedFeatures(planName, zeusLicense.Features)
data, err := newDataFromZeusLicense(zeusLicense, features)
if err != nil {
return nil, err
}
planName := valuer.NewString(planNameStr)
// if license status is invalid then default it to basic
if status == LicenseStatusInvalid {
planName = PlanNameBasic
}
state, err := extractKeyFromMapStringInterface[string](licenseData, "state")
if err != nil {
state = ""
}
freeUntilStr, err := extractKeyFromMapStringInterface[string](licenseData, "free_until")
if err != nil {
freeUntilStr = ""
}
freeUntil, err := time.Parse(time.RFC3339, freeUntilStr)
if err != nil {
freeUntil = time.Time{}
}
featuresFromZeus := make([]*Feature, 0)
if _features, ok := licenseData["features"]; ok {
featuresData, err := json.Marshal(_features)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal features data")
}
if err := json.Unmarshal(featuresData, &featuresFromZeus); err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to unmarshal features data")
}
}
switch planName {
case PlanNameEnterprise:
features = append(features, EnterprisePlan...)
case PlanNameBasic:
features = append(features, BasicPlan...)
default:
features = append(features, BasicPlan...)
}
if len(featuresFromZeus) > 0 {
for _, feature := range featuresFromZeus {
exists := false
for i, existingFeature := range features {
if existingFeature.Name == feature.Name {
features[i] = feature // Replace existing feature
exists = true
break
}
}
if !exists {
features = append(features, feature) // Append if it doesn't exist
}
}
}
licenseData["features"] = features
_validFrom, err := extractKeyFromMapStringInterface[float64](licenseData, "valid_from")
if err != nil {
_validFrom = 0
}
validFrom := int64(_validFrom)
_validUntil, err := extractKeyFromMapStringInterface[float64](licenseData, "valid_until")
if err != nil {
_validUntil = 0
}
validUntil := int64(_validUntil)
return &License{
ID: licenseID,
Key: licenseKey,
Data: licenseData,
PlanName: planName,
ID: zeusLicense.ID,
Key: zeusLicense.Key,
Data: data,
Plan: newLicensePlanFromZeusLicense(zeusLicense, planName),
EventQueue: newLicenseEventQueueFromZeusLicense(zeusLicense),
Features: features,
ValidFrom: validFrom,
ValidUntil: validUntil,
ValidFrom: zeusLicense.ValidFrom,
ValidUntil: zeusLicense.ValidUntil,
Status: status,
State: state,
FreeUntil: freeUntil,
State: valuer.NewString(zeusLicense.State),
Platform: valuer.NewString(zeusLicense.Platform),
FreeUntil: zeusLicense.FreeUntil,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
LastValidatedAt: time.Now(),
OrganizationID: organizationID,
}, nil
}
func NewLicenseFromStorableLicense(storableLicense *StorableLicense) (*License, error) {
var features []*Feature
// extract status from data
statusStr, err := extractKeyFromMapStringInterface[string](storableLicense.Data, "status")
if err != nil {
return nil, err
}
status := valuer.NewString(statusStr)
planMap, err := extractKeyFromMapStringInterface[map[string]any](storableLicense.Data, "plan")
zeusLicense, err := NewZeusLicenseFromData(storableLicense.Data)
if err != nil {
return nil, err
}
planNameStr, err := extractKeyFromMapStringInterface[string](planMap, "name")
planName, status, err := newPlanNameAndStatusFromZeusLicense(zeusLicense)
if err != nil {
return nil, err
}
planName := valuer.NewString(planNameStr)
// if license status is invalid then default it to basic
if status == LicenseStatusInvalid {
planName = PlanNameBasic
}
featuresFromZeus := make([]*Feature, 0)
if _features, ok := storableLicense.Data["features"]; ok {
featuresData, err := json.Marshal(_features)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal features data")
}
if err := json.Unmarshal(featuresData, &featuresFromZeus); err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to unmarshal features data")
}
}
switch planName {
case PlanNameEnterprise:
features = append(features, EnterprisePlan...)
case PlanNameBasic:
features = append(features, BasicPlan...)
default:
features = append(features, BasicPlan...)
}
if len(featuresFromZeus) > 0 {
for _, feature := range featuresFromZeus {
exists := false
for i, existingFeature := range features {
if existingFeature.Name == feature.Name {
features[i] = feature // Replace existing feature
exists = true
break
}
}
if !exists {
features = append(features, feature) // Append if it doesn't exist
}
}
}
features := newMergedFeatures(planName, zeusLicense.Features)
storableLicense.Data["features"] = features
_validFrom, err := extractKeyFromMapStringInterface[float64](storableLicense.Data, "valid_from")
if err != nil {
_validFrom = 0
}
validFrom := int64(_validFrom)
_validUntil, err := extractKeyFromMapStringInterface[float64](storableLicense.Data, "valid_until")
if err != nil {
_validUntil = 0
}
validUntil := int64(_validUntil)
state, err := extractKeyFromMapStringInterface[string](storableLicense.Data, "state")
if err != nil {
state = ""
}
freeUntilStr, err := extractKeyFromMapStringInterface[string](storableLicense.Data, "free_until")
if err != nil {
freeUntilStr = ""
}
freeUntil, err := time.Parse(time.RFC3339, freeUntilStr)
if err != nil {
freeUntil = time.Time{}
}
return &License{
ID: storableLicense.ID,
Key: storableLicense.Key,
Data: storableLicense.Data,
PlanName: planName,
Plan: newLicensePlanFromZeusLicense(zeusLicense, planName),
EventQueue: newLicenseEventQueueFromZeusLicense(zeusLicense),
Features: features,
ValidFrom: validFrom,
ValidUntil: validUntil,
ValidFrom: zeusLicense.ValidFrom,
ValidUntil: zeusLicense.ValidUntil,
Status: status,
State: state,
FreeUntil: freeUntil,
State: valuer.NewString(zeusLicense.State),
Platform: valuer.NewString(zeusLicense.Platform),
FreeUntil: zeusLicense.FreeUntil,
CreatedAt: storableLicense.CreatedAt,
UpdatedAt: storableLicense.UpdatedAt,
LastValidatedAt: storableLicense.LastValidatedAt,
OrganizationID: storableLicense.OrgID,
}, nil
}
func NewZeusLicenseFromData(data map[string]any) (*zeustypes.License, error) {
dataBytes, err := json.Marshal(data)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal license data")
}
zeusLicense := new(zeustypes.License)
if err := json.Unmarshal(dataBytes, zeusLicense); err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to unmarshal license data")
}
return zeusLicense, nil
}
func newPlanNameAndStatusFromZeusLicense(zeusLicense *zeustypes.License) (valuer.String, valuer.String, error) {
if zeusLicense.Status == "" {
return valuer.String{}, valuer.String{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "license status is missing")
}
if zeusLicense.Plan.Name == "" {
return valuer.String{}, valuer.String{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "license plan name is missing")
}
status := valuer.NewString(zeusLicense.Status)
planName := valuer.NewString(zeusLicense.Plan.Name)
// if license status is invalid then default it to basic
if status == LicenseStatusInvalid {
planName = PlanNameBasic
}
return planName, status, nil
}
func newLicensePlanFromZeusLicense(zeusLicense *zeustypes.License, planName valuer.String) LicensePlan {
return LicensePlan{
ID: zeusLicense.Plan.ID,
Name: planName,
Description: zeusLicense.Plan.Description,
IsActive: zeusLicense.Plan.IsActive,
CreatedAt: zeusLicense.Plan.CreatedAt,
UpdatedAt: zeusLicense.Plan.UpdatedAt,
}
}
func newLicenseEventQueueFromZeusLicense(zeusLicense *zeustypes.License) LicenseEventQueue {
return LicenseEventQueue{
Event: valuer.NewString(zeusLicense.EventQueue.Event),
Status: valuer.NewString(zeusLicense.EventQueue.Status),
ScheduledAt: zeusLicense.EventQueue.ScheduledAt,
CreatedAt: zeusLicense.EventQueue.CreatedAt,
UpdatedAt: zeusLicense.EventQueue.UpdatedAt,
}
}
func newMergedFeatures(planName valuer.String, zeusFeatures []zeustypes.LicenseFeature) []*Feature {
features := make([]*Feature, 0)
switch planName {
case PlanNameEnterprise:
features = append(features, EnterprisePlan...)
default:
features = append(features, BasicPlan...)
}
for _, zeusFeature := range zeusFeatures {
feature := &Feature{
Name: valuer.NewString(zeusFeature.Name),
Active: zeusFeature.Active,
Usage: zeusFeature.Usage,
UsageLimit: zeusFeature.UsageLimit,
Route: zeusFeature.Route,
}
exists := false
for i, existingFeature := range features {
if existingFeature.Name == feature.Name {
features[i] = feature
exists = true
break
}
}
if !exists {
features = append(features, feature)
}
}
return features
}
func newDataFromZeusLicense(zeusLicense *zeustypes.License, features []*Feature) (map[string]any, error) {
dataBytes, err := json.Marshal(zeusLicense)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal license data")
}
data := map[string]any{}
if err := json.Unmarshal(dataBytes, &data); err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to unmarshal license data")
}
delete(data, "id")
delete(data, "key")
data["features"] = features
return data, nil
}
// ErrIfCloud returns an error if the license is managed by SigNoz Cloud. The
// caller should enrich the error with the specific operation using errors.WithAdditionalf.
func (license *License) ErrIfCloud() error {
if license.Platform == LicensePlatformCloud {
return errors.New(errors.TypeInvalidInput, ErrCodeCloudLicenseOperationUnsupported, "this operation is not supported for licenses managed by SigNoz Cloud")
}
return nil
}
func NewStatsFromLicense(license *License) map[string]any {
return map[string]any{
"license.id": license.ID.StringValue(),
"license.plan.name": license.PlanName.StringValue(),
"license.state.name": license.State,
"license.plan.name": license.Plan.Name.StringValue(),
"license.state.name": license.State.StringValue(),
"license.free_until.time": license.FreeUntil.UTC(),
}
}
@@ -371,8 +334,8 @@ func (license *License) UpdateFeatures(features []*Feature) {
license.Features = features
}
func (license *License) Update(data []byte) error {
updatedLicense, err := NewLicense(data, license.OrganizationID)
func (license *License) Update(zeusLicense *zeustypes.License) error {
updatedLicense, err := NewLicense(zeusLicense, license.OrganizationID)
if err != nil {
return err
}
@@ -382,8 +345,11 @@ func (license *License) Update(data []byte) error {
license.Features = updatedLicense.Features
license.ID = updatedLicense.ID
license.Key = updatedLicense.Key
license.PlanName = updatedLicense.PlanName
license.Plan = updatedLicense.Plan
license.EventQueue = updatedLicense.EventQueue
license.Status = updatedLicense.Status
license.State = updatedLicense.State
license.Platform = updatedLicense.Platform
license.ValidFrom = updatedLicense.ValidFrom
license.ValidUntil = updatedLicense.ValidUntil
license.UpdatedAt = currentTime
@@ -392,13 +358,28 @@ func (license *License) Update(data []byte) error {
return nil
}
func NewGettableLicense(data map[string]any, key string) *GettableLicense {
gettableLicense := make(GettableLicense)
for k, v := range data {
gettableLicense[k] = v
func NewGettableLicense(license *License) *GettableLicense {
return &GettableLicense{
ID: license.ID,
ValidFrom: license.ValidFrom,
ValidUntil: license.ValidUntil,
Status: license.Status,
State: license.State,
Platform: license.Platform,
FreeUntil: license.FreeUntil,
CreatedAt: license.CreatedAt,
UpdatedAt: license.UpdatedAt,
Plan: license.Plan,
Features: license.Features,
EventQueue: license.EventQueue,
}
}
func NewGettableLicenseWithKey(license *License) *GettableLicenseWithKey {
return &GettableLicenseWithKey{
GettableLicense: *NewGettableLicense(license),
Key: license.Key,
}
gettableLicense["key"] = key
return &gettableLicense
}
func (p *PostableLicense) UnmarshalJSON(data []byte) error {
@@ -424,4 +405,5 @@ type Store interface {
Get(context.Context, valuer.UUID, valuer.UUID) (*StorableLicense, error)
GetAll(context.Context, valuer.UUID) ([]*StorableLicense, error)
Update(context.Context, valuer.UUID, *StorableLicense) error
Delete(context.Context, valuer.UUID, valuer.UUID) error
}

View File

@@ -1,178 +1,135 @@
package licensetypes
import (
"encoding/json"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/types/zeustypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewLicenseV3(t *testing.T) {
func TestNewLicenseValidation(t *testing.T) {
organizationID := valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e")
testCases := []struct {
name string
data []byte
pass bool
expected *License
error error
name string
data string
errorContains string
}{
{
name: "Error for missing license id",
data: []byte(`{}`),
pass: false,
error: errors.New("id key is missing"),
name: "missing license id",
data: `{}`,
errorContains: "license id is missing",
},
{
name: "Error for license id not being a valid string",
data: []byte(`{"id": 10}`),
pass: false,
error: errors.New("id key is not a valid string"),
name: "missing license key",
data: `{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e"}`,
errorContains: "license key is missing",
},
{
name: "Error for missing license key",
data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e"}`),
pass: false,
error: errors.New("key key is missing"),
name: "missing license status",
data: `{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter"}`,
errorContains: "license status is missing",
},
{
name: "Error for invalid string license key",
data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":10}`),
pass: false,
error: errors.New("key key is not a valid string"),
},
{
name: "Error for missing license status",
data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e", "key": "does-not-matter","category":"FREE"}`),
pass: false,
error: errors.New("status key is missing"),
},
{
name: "Error for invalid string license status",
data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key": "does-not-matter", "category":"FREE", "status":10}`),
pass: false,
error: errors.New("status key is not a valid string"),
},
{
name: "Error for missing license plan",
data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","category":"FREE","status":"ACTIVE"}`),
pass: false,
error: errors.New("plan key is missing"),
},
{
name: "Error for invalid json license plan",
data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":10}`),
pass: false,
error: errors.New("plan key is not a valid map[string]interface {}"),
},
{
name: "Error for invalid license plan",
data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{}}`),
pass: false,
error: errors.New("name key is missing"),
},
{
name: "Parse the entire license properly",
data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{"name":"ENTERPRISE"},"valid_from": 1730899309,"valid_until": -1,"state":"test","free_until":"2025-05-16T11:17:48.124202Z"}`),
pass: true,
expected: &License{
ID: valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"),
Key: "does-not-matter-key",
Data: map[string]interface{}{
"plan": map[string]interface{}{
"name": "ENTERPRISE",
},
"category": "FREE",
"status": "ACTIVE",
"valid_from": float64(1730899309),
"valid_until": float64(-1),
"state": "test",
"free_until": "2025-05-16T11:17:48.124202Z",
},
PlanName: PlanNameEnterprise,
ValidFrom: 1730899309,
ValidUntil: -1,
Status: valuer.NewString("ACTIVE"),
State: "test",
FreeUntil: time.Date(2025, 5, 16, 11, 17, 48, 124202000, time.UTC),
Features: make([]*Feature, 0),
OrganizationID: valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"),
},
},
{
name: "Fallback to basic plan if license status is invalid",
data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","category":"FREE","status":"INVALID","plan":{"name":"ENTERPRISE"},"valid_from": 1730899309,"valid_until": -1}`),
pass: true,
expected: &License{
ID: valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"),
Key: "does-not-matter-key",
Data: map[string]interface{}{
"plan": map[string]interface{}{
"name": "ENTERPRISE",
},
"category": "FREE",
"status": "INVALID",
"valid_from": float64(1730899309),
"valid_until": float64(-1),
},
PlanName: PlanNameBasic,
ValidFrom: 1730899309,
ValidUntil: -1,
Status: valuer.NewString("INVALID"),
Features: make([]*Feature, 0),
OrganizationID: valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"),
},
},
{
name: "fallback states for validFrom and validUntil",
data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{"name":"ENTERPRISE"},"valid_from":1234.456,"valid_until":5678.567}`),
pass: true,
expected: &License{
ID: valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"),
Key: "does-not-matter-key",
Data: map[string]interface{}{
"plan": map[string]interface{}{
"name": "ENTERPRISE",
},
"valid_from": 1234.456,
"valid_until": 5678.567,
"category": "FREE",
"status": "ACTIVE",
},
PlanName: PlanNameEnterprise,
ValidFrom: 1234,
ValidUntil: 5678,
Status: valuer.NewString("ACTIVE"),
Features: make([]*Feature, 0),
CreatedAt: time.Time{},
UpdatedAt: time.Time{},
LastValidatedAt: time.Time{},
OrganizationID: valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"),
},
name: "missing license plan name",
data: `{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter","status":"ACTIVE","plan":{}}`,
errorContains: "license plan name is missing",
},
}
for _, tc := range testCases {
license, err := NewLicense(tc.data, valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"))
if license != nil {
license.Features = make([]*Feature, 0)
delete(license.Data, "features")
}
if tc.pass {
require.NoError(t, err)
require.NotNil(t, license)
// as the new license will pick the time.Now() value. doesn't make sense to compare them
license.CreatedAt = time.Time{}
license.UpdatedAt = time.Time{}
license.LastValidatedAt = time.Time{}
assert.Equal(t, tc.expected, license)
} else {
require.Error(t, err)
assert.EqualError(t, err, tc.error.Error())
require.Nil(t, license)
}
zeusLicense := new(zeustypes.License)
require.NoError(t, json.Unmarshal([]byte(tc.data), zeusLicense), tc.name)
license, err := NewLicense(zeusLicense, organizationID)
require.Error(t, err, tc.name)
assert.ErrorContains(t, err, tc.errorContains, tc.name)
require.Nil(t, license, tc.name)
}
}
func TestNewLicense(t *testing.T) {
organizationID := valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e")
zeusLicense := new(zeustypes.License)
require.NoError(t, json.Unmarshal([]byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","status":"ACTIVE","state":"EVALUATING","platform":"SELF_HOSTED","plan":{"name":"ENTERPRISE"},"valid_from":1730899309,"valid_until":-1,"free_until":"2025-05-16T11:17:48.124202Z","features":[{"name":"sso","active":true,"usage":0,"usage_limit":-1,"route":""}],"event_queue":{"event":"DEFAULT","status":"SCHEDULED"}}`), zeusLicense))
license, err := NewLicense(zeusLicense, organizationID)
require.NoError(t, err)
assert.Equal(t, valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"), license.ID)
assert.Equal(t, "does-not-matter-key", license.Key)
assert.Equal(t, PlanNameEnterprise, license.Plan.Name)
assert.Equal(t, valuer.NewString("active"), license.Status)
assert.Equal(t, valuer.NewString("evaluating"), license.State)
assert.Equal(t, LicensePlatformSelfHosted, license.Platform)
assert.Equal(t, valuer.NewString("default"), license.EventQueue.Event)
assert.Equal(t, valuer.NewString("scheduled"), license.EventQueue.Status)
assert.Equal(t, int64(1730899309), license.ValidFrom)
assert.Equal(t, int64(-1), license.ValidUntil)
assert.Equal(t, time.Date(2025, 5, 16, 11, 17, 48, 124202000, time.UTC), license.FreeUntil)
assert.Equal(t, organizationID, license.OrganizationID)
ssoFeature := false
for _, feature := range license.Features {
if feature.Name == SSO {
ssoFeature = feature.Active
}
}
assert.True(t, ssoFeature)
assert.NotContains(t, license.Data, "id")
assert.NotContains(t, license.Data, "key")
assert.Equal(t, "ACTIVE", license.Data["status"])
gettableLicense := NewGettableLicense(license)
assert.Equal(t, license.ID, gettableLicense.ID)
assert.Equal(t, valuer.NewString("active"), gettableLicense.Status)
assert.Equal(t, LicensePlatformSelfHosted, gettableLicense.Platform)
assert.Equal(t, PlanNameEnterprise, gettableLicense.Plan.Name)
gettableLicenseWithKey := NewGettableLicenseWithKey(license)
assert.Equal(t, "does-not-matter-key", gettableLicenseWithKey.Key)
}
func TestNewLicenseFallsBackToBasicPlanOnInvalidStatus(t *testing.T) {
organizationID := valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e")
zeusLicense := new(zeustypes.License)
require.NoError(t, json.Unmarshal([]byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","status":"INVALID","plan":{"name":"ENTERPRISE"},"valid_from":1730899309,"valid_until":-1}`), zeusLicense))
license, err := NewLicense(zeusLicense, organizationID)
require.NoError(t, err)
assert.Equal(t, PlanNameBasic, license.Plan.Name)
}
func TestNewLicenseFromStorableLicenseRoundTrip(t *testing.T) {
organizationID := valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e")
zeusLicense := new(zeustypes.License)
require.NoError(t, json.Unmarshal([]byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","status":"ACTIVE","state":"EVALUATING","platform":"CLOUD","plan":{"name":"ENTERPRISE"},"valid_from":1730899309,"valid_until":-1}`), zeusLicense))
license, err := NewLicense(zeusLicense, organizationID)
require.NoError(t, err)
storableLicense := NewStorableLicenseFromLicense(license)
roundTrippedLicense, err := NewLicenseFromStorableLicense(storableLicense)
require.NoError(t, err)
assert.Equal(t, license.ID, roundTrippedLicense.ID)
assert.Equal(t, license.Key, roundTrippedLicense.Key)
assert.Equal(t, license.Plan.Name, roundTrippedLicense.Plan.Name)
assert.Equal(t, license.Status, roundTrippedLicense.Status)
assert.Equal(t, license.State, roundTrippedLicense.State)
assert.Equal(t, LicensePlatformCloud, roundTrippedLicense.Platform)
assert.Equal(t, license.ValidFrom, roundTrippedLicense.ValidFrom)
assert.Equal(t, license.ValidUntil, roundTrippedLicense.ValidUntil)
assert.ErrorContains(t, roundTrippedLicense.ErrIfCloud(), "not supported for licenses managed by SigNoz Cloud")
}

View File

@@ -17,6 +17,10 @@ var (
// License State.
LicenseStatusInvalid = valuer.NewString("invalid")
// License Platform.
LicensePlatformCloud = valuer.NewString("cloud")
LicensePlatformSelfHosted = valuer.NewString("self_hosted")
// Plan.
PlanNameEnterprise = valuer.NewString("enterprise")
PlanNameBasic = valuer.NewString("basic")

View File

@@ -23,6 +23,7 @@ var (
NameSpanDetailsPreviewAttributes = Name{valuer.NewString("span_details_preview_attributes")}
NameSpanDetailsColorByAttribute = Name{valuer.NewString("span_details_color_by_attribute")}
NameSpanPercentileResourceAttributes = Name{valuer.NewString("span_percentile_resource_attributes")}
NameLogDetailsPinnedAttributes = Name{valuer.NewString("log_details_pinned_attributes")}
)
type Name struct{ valuer.String }
@@ -45,6 +46,7 @@ func NewName(name string) (Name, error) {
NameSpanDetailsPreviewAttributes.StringValue(),
NameSpanDetailsColorByAttribute.StringValue(),
NameSpanPercentileResourceAttributes.StringValue(),
NameLogDetailsPinnedAttributes.StringValue(),
},
name,
)

View File

@@ -190,6 +190,15 @@ func NewAvailablePreference() map[Name]Preference {
AllowedValues: []string{},
Value: MustNewValue([]any{}, ValueTypeArray),
},
NameLogDetailsPinnedAttributes: {
Name: NameLogDetailsPinnedAttributes,
Description: "List of pinned attributes in log details drawer.",
ValueType: ValueTypeArray,
DefaultValue: MustNewValue([]any{}, ValueTypeArray),
AllowedScopes: []Scope{ScopeUser},
AllowedValues: []string{},
Value: MustNewValue([]any{}, ValueTypeArray),
},
}
}

View File

@@ -0,0 +1,49 @@
package zeustypes
import (
"time"
"github.com/SigNoz/signoz/pkg/valuer"
)
type LicenseFeature struct {
Name string `json:"name"`
Active bool `json:"active"`
Usage int64 `json:"usage"`
UsageLimit int64 `json:"usage_limit"`
Route string `json:"route"`
}
type LicensePlan struct {
ID valuer.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type LicenseEventQueue struct {
Event string `json:"event"`
Status string `json:"status"`
ScheduledAt time.Time `json:"scheduled_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type License struct {
ID valuer.UUID `json:"id"`
Key string `json:"key"`
ValidFrom int64 `json:"valid_from"`
ValidUntil int64 `json:"valid_until"`
Status string `json:"status"`
State string `json:"state"`
Platform string `json:"platform"`
FreeUntil time.Time `json:"free_until"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PlanID valuer.UUID `json:"plan_id"`
Plan LicensePlan `json:"plan"`
Features []LicenseFeature `json:"features"`
EventQueue LicenseEventQueue `json:"event_queue"`
}

View File

@@ -21,7 +21,7 @@ func New(_ context.Context, _ factory.ProviderSettings, _ zeus.Config) (zeus.Zeu
return &provider{}, nil
}
func (provider *provider) GetLicense(_ context.Context, _ string) ([]byte, error) {
func (provider *provider) GetLicense(_ context.Context, _ string) (*zeustypes.License, error) {
return nil, errors.New(errors.TypeUnsupported, zeus.ErrCodeUnsupported, "fetching license is not supported")
}

View File

@@ -15,7 +15,7 @@ var (
type Zeus interface {
// Returns the license for the given key.
GetLicense(context.Context, string) ([]byte, error)
GetLicense(context.Context, string) (*zeustypes.License, error)
// Returns the checkout URL for the given license key.
GetCheckoutURL(context.Context, string, []byte) ([]byte, error)

View File

@@ -0,0 +1,5 @@
{"metric_name":"cpu_percent_promql_subquery_no_step","labels":{"host":"server-01","cpu":"cpu0"},"timestamp":"2026-01-29T10:01:00+00:00","value":15,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"cpu_percent_promql_subquery_no_step","labels":{"host":"server-01","cpu":"cpu0"},"timestamp":"2026-01-29T10:02:00+00:00","value":15,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"cpu_percent_promql_subquery_no_step","labels":{"host":"server-01","cpu":"cpu0"},"timestamp":"2026-01-29T10:03:00+00:00","value":15,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"cpu_percent_promql_subquery_no_step","labels":{"host":"server-01","cpu":"cpu0"},"timestamp":"2026-01-29T10:04:00+00:00","value":15,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"cpu_percent_promql_subquery_no_step","labels":{"host":"server-01","cpu":"cpu0"},"timestamp":"2026-01-29T10:05:00+00:00","value":15,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}

View File

@@ -0,0 +1,58 @@
{
"alert": "promql_subquery_no_step",
"ruleType": "promql_rule",
"alertType": "METRIC_BASED_ALERT",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [
{
"name": "critical",
"target": 10,
"matchType": "at_least_once",
"op": "above",
"channels": [
"test channel"
]
}
]
},
"compositeQuery": {
"queryType": "promql",
"panelType": "graph",
"queries": [
{
"type": "promql",
"spec": {
"name": "A",
"query": "max_over_time({\"cpu_percent_promql_subquery_no_step\"}[2m:])"
}
}
]
},
"selectedQueryName": "A"
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "5m0s",
"frequency": "15s"
}
},
"labels": {},
"annotations": {
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
"summary": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})"
},
"notificationSettings": {
"groupBy": [],
"usePolicy": false,
"renotify": {
"enabled": false,
"interval": "30m",
"alertStates": []
}
},
"version": "v5",
"schemaVersion": "v2alpha1"
}

View File

@@ -295,6 +295,14 @@
"list"
]
},
{
"type": "metaresource",
"kind": "deployment-host",
"verbs": [
"list",
"update"
]
},
{
"type": "metaresource",
"kind": "user-preference",
@@ -498,6 +506,13 @@
"list"
]
},
{
"type": "metaresource",
"kind": "deployment-host",
"verbs": [
"list"
]
},
{
"type": "metaresource",
"kind": "user-preference",
@@ -654,6 +669,13 @@
"list"
]
},
{
"type": "metaresource",
"kind": "deployment-host",
"verbs": [
"list"
]
},
{
"type": "metaresource",
"kind": "user-preference",

View File

@@ -0,0 +1,93 @@
import json
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from wiremock.client import HttpMethods, Mapping, MappingRequest, MappingResponse
from fixtures import types
from fixtures.alerts import (
update_rule_channel_name,
verify_webhook_alert_expectation,
)
from fixtures.fs import get_testdata_file_path
TEST_CASE = types.AlertTestCase(
name="promql_subquery_no_step",
rule_path="alerts/test_scenarios/promql_subquery_no_step/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/promql_subquery_no_step/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
should_alert=True,
wait_time_seconds=30,
expected_alerts=[
types.FiringAlert(
labels={
"alertname": "promql_subquery_no_step",
"threshold.name": "critical",
}
),
],
),
)
def test_promql_rule_subquery_without_step(
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
create_webhook_notification_channel: Callable[[str, str, dict, bool], str],
create_alert_rule: Callable[[dict], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
):
"""
A promql rule with a step-less subquery ([2m:]) must evaluate and fire.
A nil NoStepSubqueryIntervalFn segfaults the process on first evaluation.
"""
notification_channel_name = str(uuid.uuid4())
webhook_endpoint_path = f"/alert/{notification_channel_name}"
notification_url = notification_channel.container_configs["8080"].get(webhook_endpoint_path)
make_http_mocks(
notification_channel,
[
Mapping(
request=MappingRequest(
method=HttpMethods.POST,
url=webhook_endpoint_path,
),
response=MappingResponse(
status=200,
json_body={},
),
persistent=False,
)
],
)
create_webhook_notification_channel(
channel_name=notification_channel_name,
webhook_url=notification_url,
http_config={},
send_resolved=False,
)
insert_alert_data(
TEST_CASE.alert_data,
base_time=datetime.now(tz=UTC) - timedelta(minutes=5),
)
rule_path = get_testdata_file_path(TEST_CASE.rule_path)
with open(rule_path, encoding="utf-8") as f:
rule_data = json.loads(f.read())
update_rule_channel_name(rule_data, notification_channel_name)
create_alert_rule(rule_data)
verify_webhook_alert_expectation(
notification_channel,
notification_channel_name,
TEST_CASE.alert_expectation,
)

View File

@@ -0,0 +1,65 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from uuid import uuid4
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
from fixtures.querier import get_all_series, make_query_request
MINUTE_MS = 60_000
LEGS: list[tuple[str, dict | None]] = [
("default", None),
("clickhousev2", {"X-SigNoz-PromQL-Provider": "clickhousev2"}),
]
def test_promql_subquery_without_step_evaluates(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
"""
A subquery that omits its step, e.g. `metric[5m:]`, is valid PromQL: the
engine fills in its default resolution. A nil NoStepSubqueryIntervalFn
segfaults the whole process on the first such query.
"""
end_ms = (int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000) // MINUTE_MS) * MINUTE_MS
start_ms = end_ms - 30 * MINUTE_MS
metric = f"no_step_subquery_gauge_{uuid4().hex[:8]}"
insert_metrics(
[
Metrics(
metric_name=metric,
labels={"host": "server-01"},
timestamp=datetime.fromtimestamp(ts_ms / 1000, tz=UTC),
value=42.0,
)
for ts_ms in range(start_ms, end_ms + 1, MINUTE_MS)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
for leg, headers in LEGS:
query = {"type": "promql", "spec": {"name": "A", "query": f"max_over_time({metric}[5m:])"}}
response = make_query_request(signoz, token, start_ms, end_ms, [query], headers=headers)
assert response.status_code == HTTPStatus.OK, f"{leg}: {response.text[:300]}"
series = get_all_series(response.json(), "A")
assert series, f"{leg}: the subquery must return the inserted series"
values = {point["value"] for entry in series for point in entry.get("values") or []}
assert values == {42.0}, f"{leg}: {sorted(values)[:5]}"
# A plain follow-up query proves the process survived the subquery legs.
response = make_query_request(
signoz,
token,
start_ms,
end_ms,
[{"type": "promql", "spec": {"name": "A", "query": metric}}],
)
assert response.status_code == HTTPStatus.OK, response.text[:300]

View File

@@ -2,12 +2,10 @@ from collections.abc import Callable
from datetime import UTC, datetime
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metadata import AttributesMetadata, get_field_keys, get_field_values
from fixtures.querierai import ai_trace, ai_trace_mixed_spans
from fixtures.metadata import get_field_keys, get_field_values
from fixtures.querierai import ai_trace
from fixtures.traces import Traces
AI_KEYS_PATH = "/api/v1/ai_observability/fields/keys"
@@ -108,94 +106,20 @@ def test_ai_field_values_suggests_ingested_attribute_values(
assert values["stringValues"] == ["gpt-it-values"], values
@pytest.mark.parametrize(
"existing_query,search_text,expected",
[
pytest.param(None, "", {"ai-rel-a", "ai-rel-b", "ai-rel-c"}, id="no_query_scopes_to_gen_ai_spans"),
pytest.param("gen_ai.user.id = 'alice'", "", {"ai-rel-a"}, id="span_filter_narrows_under_the_gate"),
pytest.param("llm_call_count > 0", "", {"ai-rel-a", "ai-rel-b", "ai-rel-c"}, id="pure_trace_aggregate_filter_is_stripped"),
pytest.param("llm_call_count > 0 AND gen_ai.user.id = 'alice'", "", {"ai-rel-a"}, id="mixed_filter_keeps_only_the_span_part"),
pytest.param(
"llm_call_count > 0 OR gen_ai.user.id = 'alice'",
"",
{"ai-rel-a", "ai-rel-b", "ai-rel-c"},
id="class_mixing_or_drops_the_filter_not_the_request",
),
pytest.param(
"gen_ai.user.id = ",
"",
{"ai-rel-a", "ai-rel-b", "ai-rel-c"},
id="unparseable_filter_falls_back_to_the_gate",
),
pytest.param(None, "ai-rel-a", {"ai-rel-a"}, id="search_text_narrows_related_values"),
# http.request.method lives on the root span's metadata row, gen_ai.* on
# the LLM/tool/agent rows; rows are per span-shape, so the gate AND a
# cross-span attribute filter can match no single row
pytest.param("http.request.method = 'POST'", "", set(), id="cross_span_attribute_filter_matches_no_row"),
],
)
def test_ai_field_values_related_values(
def test_ai_field_values_reject_existing_query(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
insert_attributes_metadata: Callable[[list[AttributesMetadata]], None],
existing_query: str | None,
search_text: str,
expected: set[str],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
# existingQuery key resolution reads the trace keys tables, not
# attributes_metadata; a mixed trace registers the gate keys (model/tool/
# agent) plus gen_ai.user.id and http.request.method
insert_traces(ai_trace_mixed_spans(now=now, service="ai-rel-a", user="alice"))
# related values are served from attributes_metadata; one row per gate key,
# the traces row without any gate attribute and the logs row (wrong
# data_source, gate attribute present) must never surface
insert_attributes_metadata(
[
AttributesMetadata(
data_source="traces",
resource_attributes={"service.name": "ai-rel-a"},
attributes={"gen_ai.request.model": "gpt-rel", "gen_ai.user.id": "alice"},
),
AttributesMetadata(
data_source="traces",
resource_attributes={"service.name": "ai-rel-b"},
attributes={"gen_ai.tool.name": "get_weather", "gen_ai.user.id": "bob"},
),
AttributesMetadata(
data_source="traces",
resource_attributes={"service.name": "ai-rel-c"},
attributes={"gen_ai.agent.name": "chat-agent"},
),
AttributesMetadata(
data_source="traces",
resource_attributes={"service.name": "plain-rel"},
attributes={"http.request.method": "POST"},
),
AttributesMetadata(
data_source="logs",
resource_attributes={"service.name": "ai-rel-logs"},
attributes={"gen_ai.request.model": "gpt-rel"},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
params = {"name": "service.name", "searchText": search_text}
if existing_query is not None:
params["existingQuery"] = existing_query
response = get_field_values(signoz, token, params, AI_VALUES_PATH)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "success"
related = response.json()["data"]["values"].get("relatedValues") or []
assert set(related) == expected, related
response = get_field_values(
signoz,
token,
{"name": "gen_ai.request.model", "existingQuery": "service.name = 'ai-it-values'"},
AI_VALUES_PATH,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
def test_ai_field_values_of_computed_aggregate_are_empty(