Compare commits

..

37 Commits

Author SHA1 Message Date
nityanandagohain
f537154df9 Merge remote-tracking branch 'origin/main' into issue_5947 2026-09-01 19:46:16 +05:30
Nityananda Gohain
7c50fe3763 fix: quick filters old migration cleanup (#12746)
#### Description
This PR makes sure that the old migrations of quick filters are
decoupled from types as they should and not imported.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
Part of https://github.com/SigNoz/engineering-pod/issues/5947
2026-09-01 13:54:36 +00:00
Nikhil Mantri
160a1b018c feat(alert-channel-integrations): jira + jsm ops channel backend (#12478)
#### Description

Adds two Atlassian alert channels. Backend only — frontend is #12488;
channels are created via the API.

**Jira issues — `jira_configs`**

- A firing alert creates a Jira Cloud issue; when the alert resolves,
the issue is transitioned to done. A re-fire within 3 days reopens the
same issue instead of creating a new one. 3 days is default but can be
edited via frontend form.
- The issue body is rich **Atlassian Document Format (ADF)**: a status
panel, the rendered alert description, and deep-links back to SigNoz.
- Re-fires keep the issue in sync (summary and description are
refreshed), and every notification after the first — re-fire, resolve,
reopen — also posts a **comment** carrying the same rich ADF snapshot,
so the issue holds a full lifecycle timeline.
- Per-rule custom notification templates (title/body) are honored, same
as every other channel; multi-alert custom bodies render as
divider-separated sections.
- Auth is Atlassian email + API token; Atlassian **service accounts**
also work (routed via the `api.atlassian.com` gateway automatically —
the cloud id is resolved server-side and client-supplied values are
ignored). Jira Cloud only.

**JSM Ops alerts — `jsmops_configs`**

- A firing alert opens a JSM Operations alert (the ex-Opsgenie alert
product); resolve **closes** it. A fire after close opens a fresh alert
— there is no reopen window.
- Re-fires dedupe into the same alert and increment its count. The alert
description keeps the first-fire snapshot; the value-over-time story
lives in the notes.
- Every fire and the resolve appends a **note** to the alert. JSM Ops
notes support **plain text only** (they render neither HTML nor
markdown), so notes use a new plain-text renderer with links flattened
to `text (url)`.
- The alert description supports JSM's **HTML subset**, rendered from
the same markdown templates.
- Auth is the JSM integration API key. No region/site config needed.

**Also in this PR**

- Unit tests for both config types, both notifiers, and the new ADF +
plain-text renderers.
- OpenAPI spec regenerated (adds `jsmops_configs`).

#### Issues closed by this PR

Closes SigNoz/pulse-pod#168 · Discussion: SigNoz/pulse-pod#169

#### Screenshots / Screen Recordings

Jira alert issue:

<img width="1171" height="739" alt="Screenshot 2026-08-18 at 12 49
31 PM"
src="https://github.com/user-attachments/assets/1ec74757-5d4d-4534-a5ca-13bed07cce72"
/>

Jira issue comments as a timeline:

<img width="1034" height="746" alt="Screenshot 2026-08-18 at 12 50
32 PM"
src="https://github.com/user-attachments/assets/09afb687-b62b-4eb3-86ab-39489e38513e"
/>

JSM Ops alerts page look: 

<img width="1317" height="460" alt="Screenshot 2026-08-18 at 12 52
29 PM"
src="https://github.com/user-attachments/assets/dd5a60b5-ea6c-49b7-afde-f3b2c72b178a"
/>

JSM Ops alerts main body + comment timeline ( comments only support
plain text today ) :

<img width="1323" height="784" alt="Screenshot 2026-08-18 at 12 53
10 PM"
src="https://github.com/user-attachments/assets/871fb91c-307a-4c67-b2c4-bc9548506cbc"
/>

#### Additional Information

Notes for reviewers:

- Jira shadows upstream Alertmanager's `jira_configs` so our notifier
handles it instead of upstream's; this needs a small dedupe in
`PostableChannel.JSONSchema()` and leaves every other channel type
untouched.
- JSM Ops reuses the existing Opsgenie notifier; all new behaviour sits
behind a single `advancedFeatures` flag, so plain Opsgenie is unchanged
when it's off.
- `send_resolved` defaults off for both channels, so resolve-time
behaviour (Jira transition, JSM close + resolved note) needs it set on
the channel; the frontend will send it on by default.
- Notes are best-effort: a permanently-failed note (e.g. the first-fire
note racing JSM's asynchronous alert create) is dropped with a warning
instead of failing the whole notification. Nothing is lost — that first
datapoint is already in the alert body; retryable failures (429) still
retry.

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-09-01 13:03:48 +00:00
Naman Verma
27acdbd970 fix: ensure that alerts created as disabled don't actually fire (#12743)
<!--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

Patch and Edit Rule APIs would call syncRuleStateWithTask instead of
adding the task blindly, which is what the create API was doing. This PR
fixes the incorrect call in the create API

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

Closes https://github.com/SigNoz/pulse-pod/issues/312
2026-09-01 11:26:43 +00:00
Nityananda Gohain
7eb610287e feat: system dashboards (#12620)
#### Description
Adding support for system dashboards.
* as of now updates are only through new versions in the file.
* user cannot update the dashboard
* For now kept the dashboard content empty and will raise it separately.


Closes https://github.com/SigNoz/engineering-pod/issues/4501
2026-09-01 10:17:10 +00:00
Tushar Vats
74c946fe79 fix(querier): decode JSON columns in table and graph queries (#12555)
#### Description

**What was broken:** on a stack using the new JSON log body, any query
asking for a **table** (`scalar`) or a **graph** (`time_series`) failed
with HTTP 500 if the result contained a JSON column. The logs list view
worked fine, which is why this went unnoticed. The smallest way to hit
it is a raw ClickHouse panel running `select * from
signoz_logs.logs_v2`.

**Why it happened:** we ask ClickHouse to send JSON columns as plain
text, but the driver reports that such a column needs a different Go
type — so the reader prepared the wrong kind of container and the read
failed. The driver only reports the correct type *after* the first row
has been read, which is too late for code that sets up its containers up
front. The original JSON work patched around this inside the logs-list
reader only; the connection setting that causes it is global, so the
other two readers stayed broken.

**The fix:** correct the reported type once, at the connection that sets
that option, so every reader gets a container that works and receives a
normal map. Concretely:

- `pkg/querier` no longer needs its own workaround — the three readers
are back to ordinary code.
- The older v3/v4 read paths had the same bug and are fixed without any
changes of their own.
- A JSON path value such as `body_v2.level` now comes back as `"error"`
or `7` instead of a driver wrapper object.
- Grouping a graph by the whole JSON body used to collapse every group
into a single unlabelled line; each document now labels its own series.

#### Issues closed by this PR

Fixes https://github.com/SigNoz/engineering-pod/issues/5911

#### Additional Information

Verified end to end against a local stack with 1,000,000 log rows and
200,002 distinct `trace_id`s:

| query | before | after |
| --- | --- | --- |
| table query over a JSON column | 500 | 200, body returned as an object
|
| graph grouped by the JSON body | 500 | 200, 22 series, one per
document |
| graph grouped by `trace_id` (200k groups) | 200 | 200, unchanged |

A follow-up PR stacked on this one reworks how the graph reader
classifies columns — fixing boolean and small-integer columns in raw SQL
panels and cutting the reader's allocations.

Known gaps, unchanged from `main` and out of scope here:

- Waterfall and flamegraph read rows into structs, which this fix does
not cover. Moving span attributes to JSON will need the same type on
those fields, and one helper there fails silently rather than erroring.
- Dashboard variable queries no longer crash on a JSON column but still
reject it as an unsupported value type.
- A `Map(String, JSON)` column **panics inside the driver**, which can
take the process down. Confirmed still unfixed on `clickhouse-go` main,
and not yet reported upstream.
2026-09-01 10:07:56 +00:00
Gaurav Tewari
23dd98bee7 chore: update sample json in test ai-o11y (#12741)
#### Description

- Update the sample span JSON in the LLM Observability attribute-mapping
Test tab to use OpenInference-style attribute keys (`llm.model_name`,
`llm.provider`, `llm.token_count.*`, `input.value`, `output.value`)
instead of the earlier mix of `gen_ai.*` and placeholder `my_company.*`
keys.
- The sample is what users see first when trying out attribute mapping,
so it should reflect the attribute shape they'll actually be mapping
from.

#### Issues closed by this PR

Closes -
https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=238442542&issue=SigNoz%7Cengineering-pod%7C5997

#### Screenshots / Screen Recordings

<img width="911" height="572" alt="image"
src="https://github.com/user-attachments/assets/88df20e9-0131-415c-9770-67ea8196075a"
/>


#### Additional Information

- Constant-only change (`SAMPLE_SPAN_JSON` in `spanInputStorage.ts`); no
parsing or mapping logic touched.
- Worth a sanity check that the new keys line up with the attribute
names the mapping UI is expected to suggest.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-01 09:30:30 +00:00
Swapnil Nakade
aca35a1309 chore: bumping cloud integration agent version to v0.0.14 (#12737)
<!--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
Bumping cloud integration agent version from v0.0.13 to v0.0.14

#### Contributes to
https://github.com/SigNoz/platform-pod/issues/3038
2026-09-01 09:26:50 +00:00
nityanandagohain
1c6d966afa Merge remote-tracking branch 'origin/main' into issue_5947 2026-09-01 14:50:33 +05:30
nityanandagohain
f22a18d0a7 fix: more cleanup 2026-09-01 14:49:03 +05:30
nityanandagohain
752899a7a4 fix: add to registry resource 2026-09-01 01:00:51 +05:30
nityanandagohain
eb4c570d53 fix: address comments 2026-09-01 00:35:40 +05:30
Srikanth Chekuri
c65f6fa77d fix(instrumentation): stop recording promql engine spans (#12732)
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

- The promql engine traces every evaluation through the global tracer
provider under an unnamed scope: `promqlExec`, `promqlPrepare`,
`promqlExecQueue`, and one `promqlInnerEval eval *promql.<Node>` span
per AST node per query. These didn't find much useful as the bottleneck
is usually the CH so we remove them.
2026-08-31 15:38:11 +00:00
Srikanth Chekuri
4d015a927e perf(clickhouseprometheusv2): skip the series lookup for statically named transpiled units (#12728)
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

<img width="1420" height="516" alt="image"
src="https://github.com/user-attachments/assets/cc536314-71d2-4750-b394-d53c41ac7d91"
/>

the un-needed query contributed to this data read to query service,
which had a purpose in earlier dev cycle but not longer needed.
2026-08-31 14:13:25 +00:00
Srikanth Chekuri
abebea532b feat(prometheus): add the Prometheus query API under a /prometheus prefix (#12093)
#### Description

- Adds `GET|POST /prometheus/api/v1/query_range` and
`/prometheus/api/v1/query` (`pkg/prometheus/promapi`), following the
Prometheus HTTP API contract: float-unix or RFC3339 times, float-seconds
or duration-string durations, the `{status, data, errorType, error,
warnings, infos}` envelope with Prometheus' status codes, and the
11,000-point cap.
- The `/prometheus` prefix works as a drop-in Prometheus base URL:
Grafana's Prometheus data source, promtool, and the PromQL compliance
tester append `/api/v1/*` to a base URL, so they can point at SigNoz
unmodified. Same layout as Mimir/Cortex.
- Wired through `signoz.Handlers` (`prometheus.Handler` interface,
constructed in `NewHandlers`) like the other domain handlers.
- Range queries serve through the `RangeExecutor` capability when the
provider has it, so a clickhousev2-serving deployment transpiles through
these endpoints too.
- New `promapiconformance` integration suite: the frozen promqltest
corpus replayed against these endpoints with `prometheus::provider:
clickhousev2` — the two paths nothing else exercises (v2 as serving
provider, and this API surface). Instant cases go through `/query` with
a real `time` parameter. The `instant-coarse` corpus variants are
skipped — they exist only to encode instant evals as coarse ranges for
the v5 API, and their transpiled coarse-step serving is already covered
and ledgered by promqlconformance's clickhousev2 leg — so this suite
asserts zero divergences with no ledger of its own.
- Purely additive: the existing `GET /api/v1/query_range` and `GET
/api/v1/query` handlers are untouched. `openapi.yml` is generated and
these mux-registered routes are outside the generator, so their
documentation is the upstream Prometheus API contract they follow.

#### Additional Information

Final slice of the clickhouseprometheusv2 stack (#12323, #12324, #12325
— merged). Legacy endpoint removal, if ever, is a separate change after
usage drains.
2026-08-31 12:10:05 +00:00
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
nityanandagohain
7ce405aff7 fix: updatablequickfilters updated 2026-08-31 15:40:03 +05:30
nityanandagohain
72618d1d83 Merge remote-tracking branch 'origin/main' into issue_5947 2026-08-31 15:31:19 +05:30
nityanandagohain
80b7edc22a fix: address comments 2026-08-31 15:28:58 +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
nityanandagohain
45a8bb424c Merge remote-tracking branch 'origin/main' into issue_5947 2026-08-28 20:28:22 +05:30
nityanandagohain
17afa7a3cf fix: address comments 2026-08-28 20:25:14 +05:30
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
nityanandagohain
6dc9bf7b16 Merge remote-tracking branch 'origin/main' into issue_5947 2026-08-27 14:13:30 +05:30
nityanandagohain
d15a1452f8 fix: minor fixes 2026-08-27 14:13:08 +05:30
nityanandagohain
2fe033ec78 fix: trigger build 2026-08-26 18:20:23 +05:30
nityanandagohain
02a2800f89 Merge remote-tracking branch 'origin/main' into issue_5947 2026-08-26 18:20:04 +05:30
nityanandagohain
915aa2eb70 Merge remote-tracking branch 'origin/issue_5947' into issue_5947 2026-08-26 16:44:26 +05:30
nityanandagohain
518caff0f2 Merge remote-tracking branch 'origin/main' into issue_5947 2026-08-26 16:44:09 +05:30
nityanandagohain
0d3ac28286 fix: update openapi spec 2026-08-26 16:41:50 +05:30
Nityananda Gohain
47beef07de Merge branch 'main' into issue_5947 2026-08-26 16:32:51 +05:30
nityanandagohain
5f2891bd6c fix: test cleanup 2026-08-26 16:32:01 +05:30
nityanandagohain
97f0e832ab fix: minor cleanup 2026-08-26 16:31:17 +05:30
nityanandagohain
770a8f7b0a fix: add quick filters v2 api to support TelemetryFieldKey 2026-08-26 11:12:59 +05:30
138 changed files with 9885 additions and 850 deletions

View File

@@ -50,6 +50,7 @@ jobs:
- logspipelines
- passwordauthn
- preference
- quickfilter
- querierlogs
- queriertraces
- queriermetrics
@@ -58,6 +59,7 @@ jobs:
- querierai
- rawexportdata
- promqlconformance
- promapiconformance
- querierauthz
- role
- rootuser

View File

@@ -46,6 +46,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus"
@@ -103,8 +104,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, authtypes.NewRegistry()), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, tagModule tag.Module) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, tagModule)
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, tagModule, systemDashboardRegistry)
},
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return noopgateway.NewProviderFactory()

View File

@@ -63,6 +63,7 @@ import (
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus"
@@ -136,8 +137,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
}
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, onBeforeRoleDelete, authtypes.NewRegistry()), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing, tagModule)
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing, tagModule, systemDashboardRegistry)
},
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return httpgateway.NewProviderFactory(licensing)

View File

@@ -109,6 +109,57 @@ components:
webhook_url:
$ref: '#/components/schemas/ConfigSecretURL'
type: object
AlertmanagertypesJSMOpsReceiverConfig:
properties:
api_key:
type: string
description:
type: string
http_config:
$ref: '#/components/schemas/ConfigHTTPClientConfig'
message:
type: string
priority:
type: string
send_resolved:
type: boolean
tags:
type: string
type: object
AlertmanagertypesJiraReceiverConfig:
properties:
custom_fields:
additionalProperties: {}
type: object
description:
type: string
http_config:
$ref: '#/components/schemas/ConfigHTTPClientConfig'
issue_type:
type: string
labels:
items:
type: string
type: array
priority:
type: string
project:
type: string
reopen_duration:
$ref: '#/components/schemas/ModelDuration'
reopen_transition:
type: string
resolve_transition:
type: string
send_resolved:
type: boolean
site:
type: string
summary:
type: string
wont_fix_resolution:
type: string
type: object
AlertmanagertypesMaintenanceKind:
enum:
- fixed
@@ -162,6 +213,10 @@ components:
oneOf:
- required:
- googlechat_configs
- required:
- jira_configs
- required:
- jsmops_configs
- required:
- discord_configs
- required:
@@ -192,8 +247,6 @@ components:
- msteams_configs
- required:
- msteamsv2_configs
- required:
- jira_configs
- required:
- rocketchat_configs
- required:
@@ -217,7 +270,11 @@ components:
type: array
jira_configs:
items:
$ref: '#/components/schemas/ConfigJiraConfig'
$ref: '#/components/schemas/AlertmanagertypesJiraReceiverConfig'
type: array
jsmops_configs:
items:
$ref: '#/components/schemas/AlertmanagertypesJSMOpsReceiverConfig'
type: array
mattermost_configs:
items:
@@ -344,7 +401,11 @@ components:
type: array
jira_configs:
items:
$ref: '#/components/schemas/ConfigJiraConfig'
$ref: '#/components/schemas/AlertmanagertypesJiraReceiverConfig'
type: array
jsmops_configs:
items:
$ref: '#/components/schemas/AlertmanagertypesJSMOpsReceiverConfig'
type: array
mattermost_configs:
items:
@@ -2559,6 +2620,7 @@ components:
- factor-api-key
- license
- subscription
- deployment-host
- logs
- traces
- metrics
@@ -2943,6 +3005,46 @@ components:
publicDashboard:
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
type: object
DashboardtypesGettableSystemDashboard:
properties:
createdAt:
format: date-time
type: string
createdBy:
type: string
image:
type: string
locked:
type: boolean
name:
type: string
orgId:
type: string
schemaVersion:
type: string
source:
$ref: '#/components/schemas/DashboardtypesSource'
spec:
$ref: '#/components/schemas/DashboardtypesDashboardSpec'
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
nullable: true
type: array
updatedAt:
format: date-time
type: string
updatedBy:
type: string
required:
- orgId
- locked
- source
- schemaVersion
- name
- tags
- spec
type: object
DashboardtypesHistogramBuckets:
properties:
bucketCount:
@@ -6459,6 +6561,148 @@ components:
type: object
PreferencetypesValue:
type: object
PrometheusErrorResponseSchema:
properties:
error:
type: string
errorType:
enum:
- bad_data
- execution
- canceled
- timeout
- internal
type: string
status:
enum:
- error
type: string
required:
- status
- errorType
- error
type: object
PrometheusMatrixDataSchema:
properties:
result:
items:
$ref: '#/components/schemas/PrometheusMatrixSeriesSchema'
nullable: true
type: array
resultType:
enum:
- matrix
type: string
required:
- resultType
- result
type: object
PrometheusMatrixSeriesSchema:
properties:
metric:
additionalProperties:
type: string
nullable: true
type: object
values:
items:
$ref: '#/components/schemas/PrometheusSamplePairSchema'
nullable: true
type: array
required:
- metric
- values
type: object
PrometheusQueryDataSchema:
oneOf:
- $ref: '#/components/schemas/PrometheusMatrixDataSchema'
- $ref: '#/components/schemas/PrometheusVectorDataSchema'
- $ref: '#/components/schemas/PrometheusScalarDataSchema'
- $ref: '#/components/schemas/PrometheusStringDataSchema'
type: object
PrometheusSamplePairSchema:
description: 'A [timestamp, value] pair: float unix seconds, then the string-encoded
sample value ("NaN", "+Inf", "-Inf" included).'
items:
oneOf:
- type: number
- type: string
maxItems: 2
minItems: 2
nullable: true
type: array
PrometheusScalarDataSchema:
properties:
result:
$ref: '#/components/schemas/PrometheusSamplePairSchema'
resultType:
enum:
- scalar
type: string
required:
- resultType
- result
type: object
PrometheusStringDataSchema:
properties:
result:
$ref: '#/components/schemas/PrometheusSamplePairSchema'
resultType:
enum:
- string
type: string
required:
- resultType
- result
type: object
PrometheusSuccessResponseSchema:
properties:
data:
$ref: '#/components/schemas/PrometheusQueryDataSchema'
infos:
items:
type: string
type: array
status:
enum:
- success
type: string
warnings:
items:
type: string
type: array
required:
- status
- data
type: object
PrometheusVectorDataSchema:
properties:
result:
items:
$ref: '#/components/schemas/PrometheusVectorSampleSchema'
nullable: true
type: array
resultType:
enum:
- vector
type: string
required:
- resultType
- result
type: object
PrometheusVectorSampleSchema:
properties:
metric:
additionalProperties:
type: string
nullable: true
type: object
value:
$ref: '#/components/schemas/PrometheusSamplePairSchema'
required:
- metric
- value
type: object
PromotetypesPromotePath:
properties:
indexes:
@@ -7382,6 +7626,37 @@ components:
- custom
- text
type: string
QuickfiltertypesSourceFilters:
properties:
createdAt:
format: date-time
type: string
filters:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
id:
type: string
orgId:
type: string
source:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- filters
type: object
QuickfiltertypesUpdatableQuickFilters:
properties:
filters:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
required:
- filters
type: object
RenderErrorResponse:
properties:
error:
@@ -15358,6 +15633,73 @@ paths:
summary: Migrate dashboard to v2
tags:
- dashboard
/api/v2/dashboards/system/{name}:
get:
deprecated: false
description: Returns a dashboard SigNoz ships and owns, addressed by its stable
definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards
are read-only and upgraded through releases. The dashboard's own `name` field
carries a reserved prefix that the path segment must not include.
operationId: GetSystemDashboard
parameters:
- in: path
name: name
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettableSystemDashboard'
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:
- dashboard:read
- tokenizer:
- dashboard:read
summary: Get system dashboard
tags:
- dashboard
/api/v2/factor_password/forgot:
post:
deprecated: false
@@ -18318,6 +18660,171 @@ paths:
summary: Get query range result (v2)
tags:
- dashboard
/api/v2/quick_filters:
get:
deprecated: false
description: Returns the org's quick filters for every source, each filter as
a telemetry field key.
operationId: ListQuickFilters
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/QuickfiltertypesSourceFilters'
nullable: true
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:
- quick-filter:list
- tokenizer:
- quick-filter:list
summary: List quick filters
tags:
- quick_filter
/api/v2/quick_filters/{source}:
get:
deprecated: false
description: Returns the org's quick filters for one source, each filter as
a telemetry field key.
operationId: GetQuickFilters
parameters:
- in: path
name: source
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/QuickfiltertypesSourceFilters'
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:
- quick-filter:read
- tokenizer:
- quick-filter:read
summary: Get a source's quick filters
tags:
- quick_filter
put:
deprecated: false
description: Replaces the org's quick filters for the source named in the path.
operationId: UpdateQuickFilters
parameters:
- in: path
name: source
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/QuickfiltertypesUpdatableQuickFilters'
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
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- quick-filter:update
- tokenizer:
- quick-filter:update
summary: Update quick filters
tags:
- quick_filter
/api/v2/readyz:
get:
operationId: Readyz
@@ -23942,9 +24449,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
@@ -23998,9 +24505,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
@@ -24810,6 +25317,374 @@ paths:
summary: Replace variables
tags:
- querier
/prometheus/api/v1/query:
get:
description: 'Prometheus-compatible endpoint: the request and response contract
is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/).
Parameters are accepted as URL query parameters or a form-encoded body, on
GET and POST alike.'
operationId: PrometheusQuery
parameters:
- description: PromQL expression.
in: query
name: query
required: true
schema:
description: PromQL expression.
type: string
- description: 'Evaluation timestamp: RFC3339 or float unix seconds. Defaults
to the server''s current time.'
in: query
name: time
schema:
description: 'Evaluation timestamp: RFC3339 or float unix seconds. Defaults
to the server''s current time.'
type: string
- description: 'Evaluation timeout: duration string or float seconds.'
in: query
name: timeout
schema:
description: 'Evaluation timeout: duration string or float seconds.'
type: string
- description: Any non-empty value includes query statistics in the response.
in: query
name: stats
schema:
description: Any non-empty value includes query statistics in the response.
type: string
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusSuccessResponseSchema'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
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
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Unprocessable Entity
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Internal Server Error
"503":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Service Unavailable
security:
- api_key:
- metrics:read
- tokenizer:
- metrics:read
summary: Prometheus instant query
tags:
- prometheus
post:
description: 'Prometheus-compatible endpoint: the request and response contract
is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/).
Parameters are accepted as URL query parameters or a form-encoded body, on
GET and POST alike.'
operationId: PrometheusQueryPost
parameters:
- description: PromQL expression.
in: query
name: query
required: true
schema:
description: PromQL expression.
type: string
- description: 'Evaluation timestamp: RFC3339 or float unix seconds. Defaults
to the server''s current time.'
in: query
name: time
schema:
description: 'Evaluation timestamp: RFC3339 or float unix seconds. Defaults
to the server''s current time.'
type: string
- description: 'Evaluation timeout: duration string or float seconds.'
in: query
name: timeout
schema:
description: 'Evaluation timeout: duration string or float seconds.'
type: string
- description: Any non-empty value includes query statistics in the response.
in: query
name: stats
schema:
description: Any non-empty value includes query statistics in the response.
type: string
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusSuccessResponseSchema'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
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
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Unprocessable Entity
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Internal Server Error
"503":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Service Unavailable
security:
- api_key:
- metrics:read
- tokenizer:
- metrics:read
summary: Prometheus instant query
tags:
- prometheus
/prometheus/api/v1/query_range:
get:
description: 'Prometheus-compatible endpoint: the request and response contract
is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/).
Parameters are accepted as URL query parameters or a form-encoded body, on
GET and POST alike.'
operationId: PrometheusQueryRange
parameters:
- description: PromQL expression.
in: query
name: query
required: true
schema:
description: PromQL expression.
type: string
- description: 'Range start: RFC3339 or float unix seconds.'
in: query
name: start
required: true
schema:
description: 'Range start: RFC3339 or float unix seconds.'
type: string
- description: 'Range end: RFC3339 or float unix seconds.'
in: query
name: end
required: true
schema:
description: 'Range end: RFC3339 or float unix seconds.'
type: string
- description: 'Resolution step: duration string or float seconds.'
in: query
name: step
required: true
schema:
description: 'Resolution step: duration string or float seconds.'
type: string
- description: 'Evaluation timeout: duration string or float seconds.'
in: query
name: timeout
schema:
description: 'Evaluation timeout: duration string or float seconds.'
type: string
- description: Any non-empty value includes query statistics in the response.
in: query
name: stats
schema:
description: Any non-empty value includes query statistics in the response.
type: string
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusSuccessResponseSchema'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
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
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Unprocessable Entity
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Internal Server Error
"503":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Service Unavailable
security:
- api_key:
- metrics:read
- tokenizer:
- metrics:read
summary: Prometheus range query
tags:
- prometheus
post:
description: 'Prometheus-compatible endpoint: the request and response contract
is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/).
Parameters are accepted as URL query parameters or a form-encoded body, on
GET and POST alike.'
operationId: PrometheusQueryRangePost
parameters:
- description: PromQL expression.
in: query
name: query
required: true
schema:
description: PromQL expression.
type: string
- description: 'Range start: RFC3339 or float unix seconds.'
in: query
name: start
required: true
schema:
description: 'Range start: RFC3339 or float unix seconds.'
type: string
- description: 'Range end: RFC3339 or float unix seconds.'
in: query
name: end
required: true
schema:
description: 'Range end: RFC3339 or float unix seconds.'
type: string
- description: 'Resolution step: duration string or float seconds.'
in: query
name: step
required: true
schema:
description: 'Resolution step: duration string or float seconds.'
type: string
- description: 'Evaluation timeout: duration string or float seconds.'
in: query
name: timeout
schema:
description: 'Evaluation timeout: duration string or float seconds.'
type: string
- description: Any non-empty value includes query statistics in the response.
in: query
name: stats
schema:
description: Any non-empty value includes query statistics in the response.
type: string
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusSuccessResponseSchema'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
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
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Unprocessable Entity
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Internal Server Error
"503":
content:
application/json:
schema:
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
description: Service Unavailable
security:
- api_key:
- metrics:read
- tokenizer:
- metrics:read
summary: Prometheus range query
tags:
- prometheus
servers:
- description: The fully qualified URL to the SigNoz APIServer.
url: https://{host}:{port}{base_path}

View File

@@ -299,8 +299,11 @@ substituted. One subtlety makes it exact: we write stale markers at absent
grid points. Without them, the engine's lookback would resurrect a point
from up to `lookback` earlier. The marker encodes "absent here" the way the
engine itself encodes it. Units evaluate concurrently. Each unit is one
series lookup plus one grid statement. A step of 0 is an instant query: a
single evaluation at `end`.
grid statement: the group-key join resolves the matchers, and the samples
primary key takes the metric name straight from the selector. Only a
selector without a static `__name__` runs the series lookup first, to learn
the concrete metric names. A step of 0 is an instant query: a single
evaluation at `end`.
A note on the window sliver: when the window is narrower than the step, the
grid windows cover only `window/step` of the timeline. A sample in a gap
@@ -315,8 +318,9 @@ selectors and `last_over_time` transpile at window < step too.
## Series lookup
Both paths resolve matchers the same way, once per selector
(`selectSeries`). The series tables hold one row per (fingerprint, bucket)
The engine path resolves matchers once per selector (`selectSeries`); the
transpiled path builds the same conditions into its group-key join. Both
read the same tables. The series tables hold one row per (fingerprint, bucket)
at 1h/6h/1d/1w granularities. The shared schema package
(`pkg/telemetryschema/metricstelemetryschema`) picks the table whose bucket
fits the window. It rounds the window start down to the bucket boundary, so

View File

@@ -32,9 +32,9 @@ type module struct {
tagModule tag.Module
}
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard")
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser, tagModule)
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser, tagModule, systemDashboardRegistry)
return &module{
pkgDashboardModule: pkgDashboardModule,
@@ -361,6 +361,14 @@ func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valu
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) ReconcileSystemDashboards(ctx context.Context, orgID valuer.UUID) error {
return module.pkgDashboardModule.ReconcileSystemDashboards(ctx, orgID)
}
func (module *module) GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.GetSystemDashboard(ctx, orgID, name)
}
func (module *module) delete(ctx context.Context, orgID, id valuer.UUID) error {
return module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.DeletePublic(ctx, id.String()); err != nil && !errors.Ast(err, errors.TypeNotFound) {

View File

@@ -46,6 +46,8 @@ import type {
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
GetSystemDashboard200,
GetSystemDashboardPathParameters,
ListDashboardViews200,
ListDashboardsForUserV2200,
ListDashboardsForUserV2Params,
@@ -1885,6 +1887,108 @@ export const useMigrateDashboardV2 = <
> => {
return useMutation(getMigrateDashboardV2MutationOptions(options));
};
/**
* Returns a dashboard SigNoz ships and owns, addressed by its stable definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards are read-only and upgraded through releases. The dashboard's own `name` field carries a reserved prefix that the path segment must not include.
* @summary Get system dashboard
*/
export const getSystemDashboard = (
{ name }: GetSystemDashboardPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetSystemDashboard200>({
url: `/api/v2/dashboards/system/${name}`,
method: 'GET',
signal,
});
};
export const getGetSystemDashboardQueryKey = ({
name,
}: GetSystemDashboardPathParameters) => {
return [`/api/v2/dashboards/system/${name}`] as const;
};
export const getGetSystemDashboardQueryOptions = <
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ name }: GetSystemDashboardPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetSystemDashboardQueryKey({ name });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getSystemDashboard>>
> = ({ signal }) => getSystemDashboard({ name }, signal);
return {
queryKey,
queryFn,
enabled: !!name,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetSystemDashboardQueryResult = NonNullable<
Awaited<ReturnType<typeof getSystemDashboard>>
>;
export type GetSystemDashboardQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get system dashboard
*/
export function useGetSystemDashboard<
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ name }: GetSystemDashboardPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetSystemDashboardQueryOptions({ name }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get system dashboard
*/
export const invalidateGetSystemDashboard = async (
queryClient: QueryClient,
{ name }: GetSystemDashboardPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetSystemDashboardQueryKey({ name }) },
options,
);
return queryClient;
};
/**
* This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.
* @summary Get public dashboard data (v2)

View File

@@ -0,0 +1,396 @@
/**
* ! 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 {
PrometheusErrorResponseSchemaDTO,
PrometheusQueryParams,
PrometheusQueryPostParams,
PrometheusQueryRangeParams,
PrometheusQueryRangePostParams,
PrometheusSuccessResponseSchemaDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType } from '../../../generatedAPIInstance';
/**
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
* @summary Prometheus instant query
*/
export const prometheusQuery = (
params: PrometheusQueryParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<PrometheusSuccessResponseSchemaDTO>({
url: `/prometheus/api/v1/query`,
method: 'GET',
params,
signal,
});
};
export const getPrometheusQueryQueryKey = (params?: PrometheusQueryParams) => {
return [`/prometheus/api/v1/query`, ...(params ? [params] : [])] as const;
};
export const getPrometheusQueryQueryOptions = <
TData = Awaited<ReturnType<typeof prometheusQuery>>,
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
>(
params: PrometheusQueryParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof prometheusQuery>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getPrometheusQueryQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof prometheusQuery>>> = ({
signal,
}) => prometheusQuery(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof prometheusQuery>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type PrometheusQueryQueryResult = NonNullable<
Awaited<ReturnType<typeof prometheusQuery>>
>;
export type PrometheusQueryQueryError = ErrorType<
PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO
>;
/**
* @summary Prometheus instant query
*/
export function usePrometheusQuery<
TData = Awaited<ReturnType<typeof prometheusQuery>>,
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
>(
params: PrometheusQueryParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof prometheusQuery>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getPrometheusQueryQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Prometheus instant query
*/
export const invalidatePrometheusQuery = async (
queryClient: QueryClient,
params: PrometheusQueryParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getPrometheusQueryQueryKey(params) },
options,
);
return queryClient;
};
/**
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
* @summary Prometheus instant query
*/
export const prometheusQueryPost = (
params: PrometheusQueryPostParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<PrometheusSuccessResponseSchemaDTO>({
url: `/prometheus/api/v1/query`,
method: 'POST',
params,
signal,
});
};
export const getPrometheusQueryPostMutationOptions = <
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof prometheusQueryPost>>,
TError,
{ params: PrometheusQueryPostParams },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof prometheusQueryPost>>,
TError,
{ params: PrometheusQueryPostParams },
TContext
> => {
const mutationKey = ['prometheusQueryPost'];
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 prometheusQueryPost>>,
{ params: PrometheusQueryPostParams }
> = (props) => {
const { params } = props ?? {};
return prometheusQueryPost(params);
};
return { mutationFn, ...mutationOptions };
};
export type PrometheusQueryPostMutationResult = NonNullable<
Awaited<ReturnType<typeof prometheusQueryPost>>
>;
export type PrometheusQueryPostMutationError = ErrorType<
PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO
>;
/**
* @summary Prometheus instant query
*/
export const usePrometheusQueryPost = <
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof prometheusQueryPost>>,
TError,
{ params: PrometheusQueryPostParams },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof prometheusQueryPost>>,
TError,
{ params: PrometheusQueryPostParams },
TContext
> => {
return useMutation(getPrometheusQueryPostMutationOptions(options));
};
/**
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
* @summary Prometheus range query
*/
export const prometheusQueryRange = (
params: PrometheusQueryRangeParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<PrometheusSuccessResponseSchemaDTO>({
url: `/prometheus/api/v1/query_range`,
method: 'GET',
params,
signal,
});
};
export const getPrometheusQueryRangeQueryKey = (
params?: PrometheusQueryRangeParams,
) => {
return [
`/prometheus/api/v1/query_range`,
...(params ? [params] : []),
] as const;
};
export const getPrometheusQueryRangeQueryOptions = <
TData = Awaited<ReturnType<typeof prometheusQueryRange>>,
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
>(
params: PrometheusQueryRangeParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof prometheusQueryRange>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getPrometheusQueryRangeQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof prometheusQueryRange>>
> = ({ signal }) => prometheusQueryRange(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof prometheusQueryRange>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type PrometheusQueryRangeQueryResult = NonNullable<
Awaited<ReturnType<typeof prometheusQueryRange>>
>;
export type PrometheusQueryRangeQueryError = ErrorType<
PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO
>;
/**
* @summary Prometheus range query
*/
export function usePrometheusQueryRange<
TData = Awaited<ReturnType<typeof prometheusQueryRange>>,
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
>(
params: PrometheusQueryRangeParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof prometheusQueryRange>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getPrometheusQueryRangeQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Prometheus range query
*/
export const invalidatePrometheusQueryRange = async (
queryClient: QueryClient,
params: PrometheusQueryRangeParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getPrometheusQueryRangeQueryKey(params) },
options,
);
return queryClient;
};
/**
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
* @summary Prometheus range query
*/
export const prometheusQueryRangePost = (
params: PrometheusQueryRangePostParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<PrometheusSuccessResponseSchemaDTO>({
url: `/prometheus/api/v1/query_range`,
method: 'POST',
params,
signal,
});
};
export const getPrometheusQueryRangePostMutationOptions = <
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
TError,
{ params: PrometheusQueryRangePostParams },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
TError,
{ params: PrometheusQueryRangePostParams },
TContext
> => {
const mutationKey = ['prometheusQueryRangePost'];
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 prometheusQueryRangePost>>,
{ params: PrometheusQueryRangePostParams }
> = (props) => {
const { params } = props ?? {};
return prometheusQueryRangePost(params);
};
return { mutationFn, ...mutationOptions };
};
export type PrometheusQueryRangePostMutationResult = NonNullable<
Awaited<ReturnType<typeof prometheusQueryRangePost>>
>;
export type PrometheusQueryRangePostMutationError = ErrorType<
PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO
>;
/**
* @summary Prometheus range query
*/
export const usePrometheusQueryRangePost = <
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
TError,
{ params: PrometheusQueryRangePostParams },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
TError,
{ params: PrometheusQueryRangePostParams },
TContext
> => {
return useMutation(getPrometheusQueryRangePostMutationOptions(options));
};

View File

@@ -0,0 +1,316 @@
/**
* ! 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 {
GetQuickFilters200,
GetQuickFiltersPathParameters,
ListQuickFilters200,
QuickfiltertypesUpdatableQuickFiltersDTO,
RenderErrorResponseDTO,
UpdateQuickFiltersPathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* Returns the org's quick filters for every source, each filter as a telemetry field key.
* @summary List quick filters
*/
export const listQuickFilters = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListQuickFilters200>({
url: `/api/v2/quick_filters`,
method: 'GET',
signal,
});
};
export const getListQuickFiltersQueryKey = () => {
return [`/api/v2/quick_filters`] as const;
};
export const getListQuickFiltersQueryOptions = <
TData = Awaited<ReturnType<typeof listQuickFilters>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listQuickFilters>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListQuickFiltersQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listQuickFilters>>> = ({
signal,
}) => listQuickFilters(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listQuickFilters>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListQuickFiltersQueryResult = NonNullable<
Awaited<ReturnType<typeof listQuickFilters>>
>;
export type ListQuickFiltersQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List quick filters
*/
export function useListQuickFilters<
TData = Awaited<ReturnType<typeof listQuickFilters>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listQuickFilters>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListQuickFiltersQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List quick filters
*/
export const invalidateListQuickFilters = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListQuickFiltersQueryKey() },
options,
);
return queryClient;
};
/**
* Returns the org's quick filters for one source, each filter as a telemetry field key.
* @summary Get a source's quick filters
*/
export const getQuickFilters = (
{ source }: GetQuickFiltersPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetQuickFilters200>({
url: `/api/v2/quick_filters/${source}`,
method: 'GET',
signal,
});
};
export const getGetQuickFiltersQueryKey = ({
source,
}: GetQuickFiltersPathParameters) => {
return [`/api/v2/quick_filters/${source}`] as const;
};
export const getGetQuickFiltersQueryOptions = <
TData = Awaited<ReturnType<typeof getQuickFilters>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ source }: GetQuickFiltersPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getQuickFilters>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetQuickFiltersQueryKey({ source });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getQuickFilters>>> = ({
signal,
}) => getQuickFilters({ source }, signal);
return {
queryKey,
queryFn,
enabled: !!source,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getQuickFilters>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetQuickFiltersQueryResult = NonNullable<
Awaited<ReturnType<typeof getQuickFilters>>
>;
export type GetQuickFiltersQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get a source's quick filters
*/
export function useGetQuickFilters<
TData = Awaited<ReturnType<typeof getQuickFilters>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ source }: GetQuickFiltersPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getQuickFilters>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetQuickFiltersQueryOptions({ source }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get a source's quick filters
*/
export const invalidateGetQuickFilters = async (
queryClient: QueryClient,
{ source }: GetQuickFiltersPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetQuickFiltersQueryKey({ source }) },
options,
);
return queryClient;
};
/**
* Replaces the org's quick filters for the source named in the path.
* @summary Update quick filters
*/
export const updateQuickFilters = (
{ source }: UpdateQuickFiltersPathParameters,
quickfiltertypesUpdatableQuickFiltersDTO?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/quick_filters/${source}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: quickfiltertypesUpdatableQuickFiltersDTO,
signal,
});
};
export const getUpdateQuickFiltersMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateQuickFilters>>,
TError,
{
pathParams: UpdateQuickFiltersPathParameters;
data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateQuickFilters>>,
TError,
{
pathParams: UpdateQuickFiltersPathParameters;
data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>;
},
TContext
> => {
const mutationKey = ['updateQuickFilters'];
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 updateQuickFilters>>,
{
pathParams: UpdateQuickFiltersPathParameters;
data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateQuickFilters(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateQuickFiltersMutationResult = NonNullable<
Awaited<ReturnType<typeof updateQuickFilters>>
>;
export type UpdateQuickFiltersMutationBody =
| BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>
| undefined;
export type UpdateQuickFiltersMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update quick filters
*/
export const useUpdateQuickFilters = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateQuickFilters>>,
TError,
{
pathParams: UpdateQuickFiltersPathParameters;
data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateQuickFilters>>,
TError,
{
pathParams: UpdateQuickFiltersPathParameters;
data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>;
},
TContext
> => {
return useMutation(getUpdateQuickFiltersMutationOptions(options));
};

View File

@@ -385,6 +385,93 @@ export interface AlertmanagertypesGoogleChatReceiverConfigDTO {
webhook_url?: ConfigSecretURLDTO;
}
export interface AlertmanagertypesJSMOpsReceiverConfigDTO {
/**
* @type string
*/
api_key?: string;
/**
* @type string
*/
description?: string;
http_config?: ConfigHTTPClientConfigDTO;
/**
* @type string
*/
message?: string;
/**
* @type string
*/
priority?: string;
/**
* @type boolean
*/
send_resolved?: boolean;
/**
* @type string
*/
tags?: string;
}
export type AlertmanagertypesJiraReceiverConfigDTOCustomFields = {
[key: string]: unknown;
};
export type ModelDurationDTO = number;
export interface AlertmanagertypesJiraReceiverConfigDTO {
/**
* @type object
*/
custom_fields?: AlertmanagertypesJiraReceiverConfigDTOCustomFields;
/**
* @type string
*/
description?: string;
http_config?: ConfigHTTPClientConfigDTO;
/**
* @type string
*/
issue_type?: string;
/**
* @type array
*/
labels?: string[];
/**
* @type string
*/
priority?: string;
/**
* @type string
*/
project?: string;
reopen_duration?: ModelDurationDTO;
/**
* @type string
*/
reopen_transition?: string;
/**
* @type string
*/
resolve_transition?: string;
/**
* @type boolean
*/
send_resolved?: boolean;
/**
* @type string
*/
site?: string;
/**
* @type string
*/
summary?: string;
/**
* @type string
*/
wont_fix_resolution?: string;
}
export enum AlertmanagertypesMaintenanceKindDTO {
fixed = 'fixed',
recurring = 'recurring',
@@ -631,69 +718,6 @@ export interface ConfigIncidentioConfigDTO {
url_file?: string;
}
export interface ConfigJiraFieldConfigDTO {
/**
* @type boolean,null
*/
enable_update?: boolean | null;
/**
* @type string
*/
template?: string;
}
export type ModelDurationDTO = number;
export type ConfigJiraConfigDTOCustomFields = { [key: string]: unknown };
export interface ConfigJiraConfigDTO {
/**
* @type string
*/
api_type?: string;
api_url?: ConfigURLType2DTO;
/**
* @type object
*/
custom_fields?: ConfigJiraConfigDTOCustomFields;
description?: ConfigJiraFieldConfigDTO;
http_config?: ConfigHTTPClientConfigDTO;
/**
* @type string
*/
issue_type?: string;
/**
* @type array
*/
labels?: string[];
/**
* @type string
*/
priority?: string;
/**
* @type string
*/
project?: string;
reopen_duration?: ModelDurationDTO;
/**
* @type string
*/
reopen_transition?: string;
/**
* @type string
*/
resolve_transition?: string;
/**
* @type boolean
*/
send_resolved?: boolean;
summary?: ConfigJiraFieldConfigDTO;
/**
* @type string
*/
wont_fix_resolution?: string;
}
export interface ConfigMattermostFieldDTO {
/**
* @type boolean,null
@@ -1652,7 +1676,11 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
/**
* @type array
*/
jira_configs?: ConfigJiraConfigDTO[];
jira_configs?: AlertmanagertypesJiraReceiverConfigDTO[];
/**
* @type array
*/
jsmops_configs?: AlertmanagertypesJSMOpsReceiverConfigDTO[];
/**
* @type array
*/
@@ -1779,7 +1807,11 @@ export interface AlertmanagertypesReceiverDTO {
/**
* @type array
*/
jira_configs?: ConfigJiraConfigDTO[];
jira_configs?: AlertmanagertypesJiraReceiverConfigDTO[];
/**
* @type array
*/
jsmops_configs?: AlertmanagertypesJSMOpsReceiverConfigDTO[];
/**
* @type array
*/
@@ -2175,6 +2207,7 @@ export enum CoretypesKindDTO {
'factor-api-key' = 'factor-api-key',
license = 'license',
subscription = 'subscription',
'deployment-host' = 'deployment-host',
logs = 'logs',
traces = 'traces',
metrics = 'metrics',
@@ -3267,6 +3300,67 @@ export interface CommonJSONRefDTO {
$ref?: string;
}
export type ConfigJiraConfigDTOCustomFields = { [key: string]: unknown };
export interface ConfigJiraFieldConfigDTO {
/**
* @type boolean,null
*/
enable_update?: boolean | null;
/**
* @type string
*/
template?: string;
}
export interface ConfigJiraConfigDTO {
/**
* @type string
*/
api_type?: string;
api_url?: ConfigURLType2DTO;
/**
* @type object
*/
custom_fields?: ConfigJiraConfigDTOCustomFields;
description?: ConfigJiraFieldConfigDTO;
http_config?: ConfigHTTPClientConfigDTO;
/**
* @type string
*/
issue_type?: string;
/**
* @type array
*/
labels?: string[];
/**
* @type string
*/
priority?: string;
/**
* @type string
*/
project?: string;
reopen_duration?: ModelDurationDTO;
/**
* @type string
*/
reopen_transition?: string;
/**
* @type string
*/
resolve_transition?: string;
/**
* @type boolean
*/
send_resolved?: boolean;
summary?: ConfigJiraFieldConfigDTO;
/**
* @type string
*/
wont_fix_resolution?: string;
}
export interface DashboardGridItemDTO {
content?: CommonJSONRefDTO;
/**
@@ -4959,6 +5053,53 @@ export interface DashboardtypesGettablePublicDashboardDataV2DTO {
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
}
export interface DashboardtypesGettableSystemDashboardDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
image?: string;
/**
* @type boolean
*/
locked: boolean;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
*/
schemaVersion: string;
source: DashboardtypesSourceDTO;
spec: DashboardtypesDashboardSpecDTO;
/**
* @type array,null
*/
tags: TagtypesGettableTagDTO[] | null;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export enum DashboardtypesPatchOpDTO {
add = 'add',
remove = 'remove',
@@ -7973,6 +8114,164 @@ export interface PreferencetypesUpdatablePreferenceDTO {
value?: unknown;
}
export enum PrometheusErrorResponseSchemaDTOErrorType {
bad_data = 'bad_data',
execution = 'execution',
canceled = 'canceled',
timeout = 'timeout',
internal = 'internal',
}
export enum PrometheusErrorResponseSchemaDTOStatus {
error = 'error',
}
export interface PrometheusErrorResponseSchemaDTO {
/**
* @type string
*/
error: string;
/**
* @enum bad_data,execution,canceled,timeout,internal
* @type string
*/
errorType: PrometheusErrorResponseSchemaDTOErrorType;
/**
* @enum error
* @type string
*/
status: PrometheusErrorResponseSchemaDTOStatus;
}
export enum PrometheusMatrixDataSchemaDTOResultType {
matrix = 'matrix',
}
export type PrometheusSamplePairSchemaDTOItem = number | string;
/**
* A [timestamp, value] pair: float unix seconds, then the string-encoded sample value ("NaN", "+Inf", "-Inf" included).
* @minItems 2
* @maxItems 2
* @nullable
*/
export type PrometheusSamplePairSchemaDTO =
| PrometheusSamplePairSchemaDTOItem[]
| null;
export type PrometheusMatrixSeriesSchemaDTOMetricAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type PrometheusMatrixSeriesSchemaDTOMetric =
PrometheusMatrixSeriesSchemaDTOMetricAnyOf | null;
export interface PrometheusMatrixSeriesSchemaDTO {
/**
* @type object,null
*/
metric: PrometheusMatrixSeriesSchemaDTOMetric;
/**
* @type array,null
*/
values: (PrometheusSamplePairSchemaDTO | null)[] | null;
}
export interface PrometheusMatrixDataSchemaDTO {
/**
* @type array,null
*/
result: PrometheusMatrixSeriesSchemaDTO[] | null;
/**
* @enum matrix
* @type string
*/
resultType: PrometheusMatrixDataSchemaDTOResultType;
}
export type PrometheusVectorSampleSchemaDTOMetricAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type PrometheusVectorSampleSchemaDTOMetric =
PrometheusVectorSampleSchemaDTOMetricAnyOf | null;
export interface PrometheusVectorSampleSchemaDTO {
/**
* @type object,null
*/
metric: PrometheusVectorSampleSchemaDTOMetric;
value: PrometheusSamplePairSchemaDTO | null;
}
export enum PrometheusVectorDataSchemaDTOResultType {
vector = 'vector',
}
export interface PrometheusVectorDataSchemaDTO {
/**
* @type array,null
*/
result: PrometheusVectorSampleSchemaDTO[] | null;
/**
* @enum vector
* @type string
*/
resultType: PrometheusVectorDataSchemaDTOResultType;
}
export enum PrometheusScalarDataSchemaDTOResultType {
scalar = 'scalar',
}
export interface PrometheusScalarDataSchemaDTO {
result: PrometheusSamplePairSchemaDTO | null;
/**
* @enum scalar
* @type string
*/
resultType: PrometheusScalarDataSchemaDTOResultType;
}
export enum PrometheusStringDataSchemaDTOResultType {
string = 'string',
}
export interface PrometheusStringDataSchemaDTO {
result: PrometheusSamplePairSchemaDTO | null;
/**
* @enum string
* @type string
*/
resultType: PrometheusStringDataSchemaDTOResultType;
}
export type PrometheusQueryDataSchemaDTO =
| PrometheusMatrixDataSchemaDTO
| PrometheusVectorDataSchemaDTO
| PrometheusScalarDataSchemaDTO
| PrometheusStringDataSchemaDTO;
export enum PrometheusSuccessResponseSchemaDTOStatus {
success = 'success',
}
export interface PrometheusSuccessResponseSchemaDTO {
data: PrometheusQueryDataSchemaDTO;
/**
* @type array
*/
infos?: string[];
/**
* @enum success
* @type string
*/
status: PrometheusSuccessResponseSchemaDTOStatus;
/**
* @type array
*/
warnings?: string[];
}
export interface PromotetypesWrappedIndexDTO {
fieldDataType?: TelemetrytypesFieldDataTypeDTO;
/**
@@ -8436,6 +8735,42 @@ export enum Querybuildertypesv5QueryTypeDTO {
clickhouse_sql = 'clickhouse_sql',
promql = 'promql',
}
export interface QuickfiltertypesSourceFiltersDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type array
*/
filters: TelemetrytypesTelemetryFieldKeyDTO[];
/**
* @type string
*/
id: string;
/**
* @type string
*/
orgId?: string;
/**
* @type string
*/
source?: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export interface QuickfiltertypesUpdatableQuickFiltersDTO {
/**
* @type array
*/
filters: TelemetrytypesTelemetryFieldKeyDTO[];
}
export interface RenderErrorResponseDTO {
error: ErrorsJSONDTO;
/**
@@ -11312,6 +11647,17 @@ export type MigrateDashboardV2200 = {
status: string;
};
export type GetSystemDashboardPathParameters = {
name: string;
};
export type GetSystemDashboard200 = {
data: DashboardtypesGettableSystemDashboardDTO;
/**
* @type string
*/
status: string;
};
export type GetFeatures200 = {
/**
* @type array
@@ -11826,6 +12172,31 @@ export type GetPublicDashboardPanelQueryRangeV2200 = {
status: string;
};
export type ListQuickFilters200 = {
/**
* @type array,null
*/
data: QuickfiltertypesSourceFiltersDTO[] | null;
/**
* @type string
*/
status: string;
};
export type GetQuickFiltersPathParameters = {
source: string;
};
export type GetQuickFilters200 = {
data: QuickfiltertypesSourceFiltersDTO;
/**
* @type string
*/
status: string;
};
export type UpdateQuickFiltersPathParameters = {
source: string;
};
export type Readyz200 = {
data: FactoryResponseDTO;
/**
@@ -12470,3 +12841,115 @@ export type ReplaceVariables200 = {
*/
status: string;
};
export type PrometheusQueryParams = {
/**
* @type string
* @description PromQL expression.
*/
query: string;
/**
* @type string
* @description Evaluation timestamp: RFC3339 or float unix seconds. Defaults to the server's current time.
*/
time?: string;
/**
* @type string
* @description Evaluation timeout: duration string or float seconds.
*/
timeout?: string;
/**
* @type string
* @description Any non-empty value includes query statistics in the response.
*/
stats?: string;
};
export type PrometheusQueryPostParams = {
/**
* @type string
* @description PromQL expression.
*/
query: string;
/**
* @type string
* @description Evaluation timestamp: RFC3339 or float unix seconds. Defaults to the server's current time.
*/
time?: string;
/**
* @type string
* @description Evaluation timeout: duration string or float seconds.
*/
timeout?: string;
/**
* @type string
* @description Any non-empty value includes query statistics in the response.
*/
stats?: string;
};
export type PrometheusQueryRangeParams = {
/**
* @type string
* @description PromQL expression.
*/
query: string;
/**
* @type string
* @description Range start: RFC3339 or float unix seconds.
*/
start: string;
/**
* @type string
* @description Range end: RFC3339 or float unix seconds.
*/
end: string;
/**
* @type string
* @description Resolution step: duration string or float seconds.
*/
step: string;
/**
* @type string
* @description Evaluation timeout: duration string or float seconds.
*/
timeout?: string;
/**
* @type string
* @description Any non-empty value includes query statistics in the response.
*/
stats?: string;
};
export type PrometheusQueryRangePostParams = {
/**
* @type string
* @description PromQL expression.
*/
query: string;
/**
* @type string
* @description Range start: RFC3339 or float unix seconds.
*/
start: string;
/**
* @type string
* @description Range end: RFC3339 or float unix seconds.
*/
end: string;
/**
* @type string
* @description Resolution step: duration string or float seconds.
*/
step: string;
/**
* @type string
* @description Evaluation timeout: duration string or float seconds.
*/
timeout?: string;
/**
* @type string
* @description Any non-empty value includes query statistics in the response.
*/
stats?: 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

@@ -38,19 +38,20 @@ import {
TEST_ENDPOINT,
} from '../../__tests__/fixtures';
const SAMPLE_SPAN = JSON.parse(SAMPLE_SPAN_JSON) as {
attributes: Record<string, unknown>;
resource: Record<string, unknown>;
};
const MAPPED_ATTRIBUTE_KEY = 'gen_ai.content.prompt';
// Deriving from the sample keeps exactly one key added, so the single `populated` badge assertion below stays exact.
const RESULT_SPAN = {
attributes: {
'my_company.llm.input': 'What is quantum computing?',
'llm.input_messages': 'What is quantum computing?',
'gen_ai.request.model': 'gpt-4',
'gen_ai.usage.total_tokens': 1250,
'gen_ai.content.completion': 'Quantum computing leverages...',
'gen_ai.content.prompt': 'What is quantum computing?',
},
resource: {
'service.name': 'llm-gateway',
'deployment.environment': 'production',
...SAMPLE_SPAN.attributes,
[MAPPED_ATTRIBUTE_KEY]: SAMPLE_SPAN.attributes['input.value'],
},
resource: SAMPLE_SPAN.resource,
};
const EDITED_SPAN_JSON = `{
@@ -97,7 +98,7 @@ describe('TestTab — sample-span flow', () => {
).resolves.toBeInTheDocument();
expect(screen.getByTestId('test-result-0')).toBeInTheDocument();
expect(screen.getByTestId('test-result-0-attributes')).toHaveTextContent(
'gen_ai.content.prompt',
MAPPED_ATTRIBUTE_KEY,
);
expect(screen.getByText('populated')).toBeInTheDocument();
expect(screen.queryByTestId('test-error')).not.toBeInTheDocument();

View File

@@ -7,11 +7,14 @@ import { parseSpanInput } from './testPayload';
export const SAMPLE_SPAN_JSON = `{
"attributes": {
"my_company.llm.input": "What is quantum computing?",
"llm.input_messages": "What is quantum computing?",
"gen_ai.request.model": "gpt-4",
"gen_ai.usage.total_tokens": 1250,
"gen_ai.content.completion": "Quantum computing leverages..."
"llm.model_name": "gpt-4o",
"llm.provider": "openai",
"llm.token_count.prompt": 1024,
"llm.token_count.completion": 226,
"llm.token_count.prompt_details.cache_read": 512,
"input.value": "What is quantum computing?",
"output.value": "Quantum computing leverages superposition and entanglement...",
"session.id": "chat-8f2e41"
},
"resource": {
"service.name": "llm-gateway",

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,557 @@
// Copyright (c) 2026 SigNoz, Inc.
// Copyright 2023 Prometheus Team
// SPDX-License-Identifier: Apache-2.0
package jira
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"sort"
"strings"
"time"
"unicode/utf16"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/adf"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
)
const Integration = "jira"
const (
maxSummaryLenRunes = 255
maxDescriptionLenRunes = 32767
)
// Notifier implements notify.Notifier for Jira.
type Notifier struct {
conf *alertmanagertypes.JiraReceiverConfig
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
templater alertmanagertypes.Templater
}
func New(conf *alertmanagertypes.JiraReceiverConfig, _ *template.Template, l *slog.Logger, templater alertmanagertypes.Templater) (*Notifier, error) {
if conf.HTTPConfig == nil {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira http_config is nil")
}
client, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration)
if err != nil {
return nil, err
}
return &Notifier{
conf: conf,
logger: l,
client: client,
retrier: &notify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
templater: templater,
}, nil
}
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
key, err := notify.ExtractGroupKey(ctx)
if err != nil {
return false, err
}
groupID := key.Hash()
firing := types.Alerts(as...).HasFiring()
n.logger.DebugContext(ctx, "sending jira notification", slog.String("group_key", key.String()), slog.Bool("firing", firing))
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(as)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
DefaultTitleTemplate: n.conf.Summary,
DefaultBodyTemplate: n.conf.Description,
}, as)
if err != nil {
return false, err
}
summary := truncateRunes(result.Title, maxSummaryLenRunes)
var parts []string
for _, body := range result.Body {
if body != "" {
parts = append(parts, body)
}
}
// custom body templates render per alert; join them under ADF rule dividers.
// The default body is a single combined part, so the join is a no-op there.
descText := truncateRunes(strings.Join(parts, "\n\n---\n\n"), maxDescriptionLenRunes)
baseURL, retry, err := n.resolveAPIBaseURL(ctx)
if err != nil {
return retry, err
}
existing, retry, err := n.searchIssue(ctx, baseURL, groupID, firing)
if err != nil {
return retry, err
}
fields := n.buildFields(groupID, summary, descText, as, firing)
// No existing issue: create for firing groups; never create for resolved-only.
if existing == nil {
if !firing {
return false, nil
}
return n.createIssue(ctx, baseURL, fields)
}
// Existing issue: refresh it, then transition + comment based on the new state.
if retry, err := n.updateIssue(ctx, baseURL, existing, fields); err != nil {
return retry, err
}
// Each state-change comment carries the same rich snapshot as the description
// (panel + details + deep-links), so the comment timeline mirrors the card
// Google Chat re-posts on every notification.
switch {
case firing && existing.isDone(): // re-fired after resolution → reopen
if retry, err := n.applyTransition(ctx, baseURL, existing.Key, false, n.conf.ReopenTransition); err != nil {
return retry, err
}
case !firing: // resolved (search returns only open issues, so this one is open)
if retry, err := n.applyTransition(ctx, baseURL, existing.Key, true, n.conf.ResolveTransition); err != nil {
return retry, err
}
}
// firing && !isDone (still firing) needs no transition.
return n.addComment(ctx, baseURL, existing.Key, fields.Description)
}
func (n *Notifier) buildFields(groupID, summary, descText string, alerts []*types.Alert, firing bool) *issueFields {
f := &issueFields{
Project: &idKey{Key: n.conf.Project},
Issuetype: &idName{Name: n.conf.IssueType},
Summary: summary,
Labels: n.labels(groupID),
Description: n.buildBoundedDescription(descText, alerts, firing),
}
if n.conf.Priority != "" {
f.Priority = &idName{Name: n.conf.Priority}
}
return f
}
// buildBoundedDescription builds the ADF issue body and keeps it within Jira's
// description limit, which counts text characters plus per-node overhead — so a
// text-only markdown cap is not enough. Over-limit bodies are shrunk at the
// markdown level and rebuilt; the panel and deep-links are part of the measured
// document, so the result always fits.
func (n *Notifier) buildBoundedDescription(descText string, alerts []*types.Alert, firing bool) map[string]any {
doc := n.buildDescription(descText, alerts, firing)
for range 4 {
size := adfDocLen(doc)
if size <= maxDescriptionLenRunes {
return doc
}
runes := []rune(descText)
keep := len(runes) * maxDescriptionLenRunes / size * 9 / 10
if keep >= len(runes) {
keep = len(runes) - 1
}
if keep <= 0 {
break
}
descText = string(runes[:keep]) + "…"
doc = n.buildDescription(descText, alerts, firing)
}
if adfDocLen(doc) <= maxDescriptionLenRunes {
return doc
}
// still over after shrinking: keep just the panel and deep-links
return n.buildDescription("", alerts, firing)
}
// adfDocLen approximates how Jira measures an ADF document against the 32767
// limit: text length in UTF-16 code units, plus per-node overhead (block
// boundaries count like newlines), plus link targets. Deliberately counts on
// the high side so a passing measurement never 400s.
func adfDocLen(node any) int {
m, ok := node.(map[string]any)
if !ok {
return 0
}
size := 2
if text, ok := m["text"].(string); ok {
for _, r := range text {
size += utf16.RuneLen(r)
}
}
if marks, ok := m["marks"].([]any); ok {
for _, mark := range marks {
if mm, ok := mark.(map[string]any); ok {
if attrs, ok := mm["attrs"].(map[string]any); ok {
if href, ok := attrs["href"].(string); ok {
size += len(href)
}
}
}
}
}
if content, ok := m["content"].([]any); ok {
for _, child := range content {
size += adfDocLen(child)
}
}
return size
}
// buildDescription assembles the ADF issue body: a firing/resolved status panel,
// the rendered markdown body, and SigNoz deep-links.
func (n *Notifier) buildDescription(descText string, alerts []*types.Alert, firing bool) map[string]any {
content := []any{statusPanel(firing)}
content = append(content, adf.Render(descText)...)
if links := deepLinks(alerts); links != nil {
content = append(content, links)
}
return map[string]any{"type": "doc", "version": 1, "content": content}
}
func statusPanel(firing bool) map[string]any {
panelType, label := "success", "🟢 RESOLVED"
if firing {
panelType, label = "error", "🔴 FIRING"
}
return map[string]any{
"type": "panel",
"attrs": map[string]any{"panelType": panelType},
"content": []any{map[string]any{
"type": "paragraph",
"content": []any{map[string]any{"type": "text", "text": label, "marks": []any{map[string]any{"type": "strong"}}}},
}},
}
}
// deepLinks builds a paragraph of SigNoz links from the per-rule ruleSource label
// and the related-logs/traces annotations. Returns nil when none are present.
func deepLinks(alerts []*types.Alert) map[string]any {
if len(alerts) == 0 {
return nil
}
a := alerts[0]
var parts []any
add := func(label, url string) {
if url == "" {
return
}
if len(parts) > 0 {
parts = append(parts, map[string]any{"type": "text", "text": " · "})
}
parts = append(parts, map[string]any{
"type": "text",
"text": label,
"marks": []any{map[string]any{"type": "link", "attrs": map[string]any{"href": url}}},
})
}
add("Open in SigNoz", string(a.Labels[ruletypes.LabelRuleSource]))
add("View Related Logs", string(a.Annotations[ruletypes.AnnotationRelatedLogs]))
add("View Related Traces", string(a.Annotations[ruletypes.AnnotationRelatedTraces]))
if len(parts) == 0 {
return nil
}
return map[string]any{"type": "paragraph", "content": parts}
}
func (n *Notifier) labels(groupID string) []string {
out := append([]string{}, n.conf.Labels...)
out = append(out, "signoz-alert", fmt.Sprintf("ALERT{%s}", groupID))
sort.Strings(out)
return out
}
func (n *Notifier) searchIssue(ctx context.Context, baseURL, groupID string, firing bool) (*issue, bool, error) {
var jql strings.Builder
if n.conf.WontFixResolution != "" {
// != alone also drops unresolved (EMPTY) issues, so keep those explicitly.
fmt.Fprintf(&jql, `(resolution is EMPTY or resolution != %q) and `, n.conf.WontFixResolution)
}
if reopenMin := int64(time.Duration(n.conf.ReopenDuration).Minutes()); firing && reopenMin > 0 {
fmt.Fprintf(&jql, `(resolutiondate is EMPTY OR resolutiondate >= -%dm) and `, reopenMin)
} else {
jql.WriteString(`statusCategory != Done and `)
}
fmt.Fprintf(&jql, `project=%q and labels=%q order by status ASC, resolutiondate DESC`, n.conf.Project, fmt.Sprintf("ALERT{%s}", groupID))
body, retry, err := n.callAPI(ctx, http.MethodPost, baseURL+"/search/jql", searchRequest{
JQL: jql.String(), MaxResults: 2, Fields: []string{"status", "labels"},
})
if err != nil {
return nil, retry, err
}
var res searchResult
if err := json.Unmarshal(body, &res); err != nil {
return nil, false, err
}
if len(res.Issues) == 0 {
return nil, false, nil
}
// the JQL order is not category-aware, so prefer an open issue over a done
// one; all done falls back to the most recently resolved (resolutiondate DESC)
for i := range res.Issues {
if !res.Issues[i].isDone() {
return &res.Issues[i], false, nil
}
}
return &res.Issues[0], false, nil
}
func (n *Notifier) createIssue(ctx context.Context, baseURL string, fields *issueFields) (bool, error) {
_, retry, err := n.callAPI(ctx, http.MethodPost, baseURL+"/issue", issue{Fields: fields})
return retry, err
}
func (n *Notifier) updateIssue(ctx context.Context, baseURL string, existing *issue, fields *issueFields) (bool, error) {
// project and issue type are set at creation and cannot be edited.
upd := *fields
upd.Project = nil
upd.Issuetype = nil
// Jira replaces the labels array wholesale, so union in the labels already
// on the issue to keep user-added ones.
if existing.Fields != nil {
upd.Labels = mergeLabels(existing.Fields.Labels, fields.Labels)
}
_, retry, err := n.callAPI(ctx, http.MethodPut, n.issueURL(baseURL, existing.Key, ""), issue{Fields: &upd})
return retry, err
}
func mergeLabels(existing, ours []string) []string {
seen := make(map[string]bool, len(existing)+len(ours))
var merged []string
for _, label := range append(append([]string{}, existing...), ours...) {
if !seen[label] {
seen[label] = true
merged = append(merged, label)
}
}
sort.Strings(merged)
return merged
}
// applyTransition moves the issue into (toDone) or out of (!toDone) the "done"
// status category, preferring the named override, else the first matching
// transition, else skipping without error when none is available.
func (n *Notifier) applyTransition(ctx context.Context, baseURL, key string, toDone bool, override string) (bool, error) {
transitions, retry, err := n.getTransitions(ctx, baseURL, key)
if err != nil {
return retry, err
}
id := selectTransition(transitions, toDone, override)
if id == "" {
n.logger.WarnContext(ctx, "jira: no matching transition, leaving issue as-is", slog.String("issue", key), slog.Bool("to_done", toDone))
return false, nil
}
_, retry, err = n.callAPI(ctx, http.MethodPost, n.issueURL(baseURL, key, "transitions"), issue{Transition: &idName{ID: id}})
return retry, err
}
func (n *Notifier) getTransitions(ctx context.Context, baseURL, key string) ([]jiraTransition, bool, error) {
body, retry, err := n.callAPI(ctx, http.MethodGet, n.issueURL(baseURL, key, "transitions"), nil)
if err != nil {
return nil, retry, err
}
var tr transitionsResponse
if err := json.Unmarshal(body, &tr); err != nil {
return nil, false, err
}
return tr.Transitions, false, nil
}
func (n *Notifier) addComment(ctx context.Context, baseURL, key string, body any) (bool, error) {
_, retry, err := n.callAPI(ctx, http.MethodPost, n.issueURL(baseURL, key, "comment"), comment{Body: body})
return retry, err
}
func (n *Notifier) issueURL(baseURL, key, sub string) string {
u := baseURL + "/issue/" + key
if sub != "" {
u += "/" + sub
}
return u
}
func (n *Notifier) callAPI(ctx context.Context, method, url string, reqBody any) ([]byte, bool, error) {
var body io.Reader
if reqBody != nil {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(reqBody); err != nil {
return nil, false, err
}
body = &buf
}
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, false, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := n.client.Do(req) //nolint:bodyclose // notify.Drain closes the body
if err != nil {
return nil, true, notify.RedactURL(err)
}
defer notify.Drain(resp)
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, false, err
}
shouldRetry, err := n.retrier.Check(resp.StatusCode, bytes.NewReader(respBody))
if err != nil {
return respBody, shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
}
return respBody, false, nil
}
// resolveAPIBaseURL resolves the service-account cloud id per notification (it
// is never persisted); personal API tokens use the site host directly.
func (n *Notifier) resolveAPIBaseURL(ctx context.Context) (string, bool, error) {
if !n.conf.IsServiceAccount() {
return n.conf.APIBaseURL(""), false, nil
}
cloudID, retry, err := n.resolveCloudID(ctx)
if err != nil {
return "", retry, err
}
return n.conf.APIBaseURL(cloudID), false, nil
}
// resolveCloudID fetches the site's cloud id from its unauthenticated
// tenant_info endpoint; transport failures are retryable, bad responses are not.
func (n *Notifier) resolveCloudID(ctx context.Context) (string, bool, error) {
url := strings.TrimRight(n.conf.Site, "/") + "/_edge/tenant_info"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", false, err
}
req.Header.Set("Accept", "application/json")
resp, err := n.client.Do(req)
if err != nil {
return "", true, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch jira cloud id")
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", true, err
}
if resp.StatusCode != http.StatusOK {
return "", false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to resolve jira cloud id from %s: status %d", url, resp.StatusCode)
}
var out struct {
CloudID string `json:"cloudId"`
}
if err := json.Unmarshal(body, &out); err != nil {
return "", false, errors.WrapInternalf(err, errors.CodeInternal, "failed to parse jira tenant_info response")
}
if out.CloudID == "" {
return "", false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira tenant_info returned an empty cloud id for %s", n.conf.Site)
}
return out.CloudID, false, nil
}
// selectTransition returns the id of the transition whose target status category
// matches toDone, preferring one named override when present.
func selectTransition(transitions []jiraTransition, toDone bool, override string) string {
if override != "" {
for _, t := range transitions {
if t.Name == override {
return t.ID
}
}
}
for _, t := range transitions {
if (t.To.StatusCategory.Key == "done") == toDone {
return t.ID
}
}
return ""
}
// Jira API types.
type issue struct {
Key string `json:"key,omitempty"`
Fields *issueFields `json:"fields,omitempty"`
Transition *idName `json:"transition,omitempty"`
}
type issueFields struct {
Project *idKey `json:"project,omitempty"`
Issuetype *idName `json:"issuetype,omitempty"`
Summary string `json:"summary,omitempty"`
Labels []string `json:"labels,omitempty"`
Priority *idName `json:"priority,omitempty"`
Description any `json:"description,omitempty"`
Status *issueStatus `json:"status,omitempty"`
}
type idKey struct {
Key string `json:"key"`
}
type idName struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
}
type issueStatus struct {
StatusCategory struct {
Key string `json:"key"`
} `json:"statusCategory"`
}
func (i *issue) isDone() bool {
return i.Fields != nil && i.Fields.Status != nil && i.Fields.Status.StatusCategory.Key == "done"
}
type searchRequest struct {
JQL string `json:"jql"`
MaxResults int `json:"maxResults"`
Fields []string `json:"fields"`
}
type searchResult struct {
Issues []issue `json:"issues"`
}
type transitionsResponse struct {
Transitions []jiraTransition `json:"transitions"`
}
type jiraTransition struct {
ID string `json:"id"`
Name string `json:"name"`
To struct {
StatusCategory struct {
Key string `json:"key"`
} `json:"statusCategory"`
} `json:"to"`
}
type comment struct {
Body any `json:"body"`
}
func truncateRunes(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max])
}

View File

@@ -0,0 +1,489 @@
package jira
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/notify/test"
"github.com/prometheus/alertmanager/types"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type mockReq struct {
method string
path string
body map[string]any
}
type mockJira struct {
srv *httptest.Server
mu sync.Mutex
reqs []mockReq
searchIssues []issue
transitions []jiraTransition
createStatus int
}
func newMockJira(t *testing.T) *mockJira {
t.Helper()
m := &mockJira{}
m.srv = httptest.NewServer(http.HandlerFunc(m.handle))
t.Cleanup(m.srv.Close)
return m
}
func (m *mockJira) handle(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
m.mu.Lock()
m.reqs = append(m.reqs, mockReq{r.Method, r.URL.Path, body})
m.mu.Unlock()
p := r.URL.Path
switch {
case strings.HasSuffix(p, "/search/jql"):
_ = json.NewEncoder(w).Encode(searchResult{Issues: m.searchIssues})
case strings.HasSuffix(p, "/transitions") && r.Method == http.MethodGet:
_ = json.NewEncoder(w).Encode(transitionsResponse{Transitions: m.transitions})
case strings.HasSuffix(p, "/transitions"):
w.WriteHeader(http.StatusNoContent)
case strings.HasSuffix(p, "/comment"):
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":"1"}`))
case strings.HasSuffix(p, "/issue") && r.Method == http.MethodPost:
st := m.createStatus
if st == 0 {
st = http.StatusCreated
}
w.WriteHeader(st)
_, _ = w.Write([]byte(`{"key":"KAN-1"}`))
case r.Method == http.MethodPut:
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusNotFound)
}
}
func (m *mockJira) countPost(suffix string) int {
m.mu.Lock()
defer m.mu.Unlock()
c := 0
for _, r := range m.reqs {
if r.method == http.MethodPost && strings.HasSuffix(r.path, suffix) {
c++
}
}
return c
}
func (m *mockJira) countPuts() int {
m.mu.Lock()
defer m.mu.Unlock()
c := 0
for _, r := range m.reqs {
if r.method == http.MethodPut {
c++
}
}
return c
}
func newNotifier(t *testing.T, m *mockJira) *Notifier {
t.Helper()
tmpl := test.CreateTmpl(t)
n, err := New(&alertmanagertypes.JiraReceiverConfig{
Site: m.srv.URL,
Project: "KAN",
IssueType: "Task",
Summary: alertmanagertypes.DefaultJiraSummaryTemplate,
Description: alertmanagertypes.DefaultJiraDescriptionTemplate,
HTTPConfig: &commoncfg.HTTPClientConfig{},
ReopenDuration: model.Duration(3 * 24 * time.Hour),
}, tmpl, slog.New(slog.DiscardHandler), alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler)))
require.NoError(t, err)
return n
}
func alert(firing bool) *types.Alert {
a := &types.Alert{Alert: model.Alert{
Labels: model.LabelSet{"alertname": "HighCPU", "severity": "critical"},
Annotations: model.LabelSet{"summary": "cpu high"},
StartsAt: time.Now().Add(-time.Minute),
}}
if firing {
a.EndsAt = time.Now().Add(time.Hour)
} else {
a.EndsAt = time.Now().Add(-time.Minute)
}
return a
}
func ctx() context.Context {
return notify.WithGroupKey(context.Background(), "test-jira")
}
func doneIssue() issue {
i := issue{Key: "KAN-1", Fields: &issueFields{Status: &issueStatus{}}}
i.Fields.Status.StatusCategory.Key = "done"
return i
}
func openIssue() issue {
i := issue{Key: "KAN-1", Fields: &issueFields{Status: &issueStatus{}}}
i.Fields.Status.StatusCategory.Key = "new"
return i
}
func transition(id, name, category string) jiraTransition {
tr := jiraTransition{ID: id, Name: name}
tr.To.StatusCategory.Key = category
return tr
}
func TestNotifyCreatesWhenNoExistingIssue(t *testing.T) {
m := newMockJira(t)
retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
require.NoError(t, err)
assert.False(t, retry)
assert.Equal(t, 1, m.countPost("/issue"))
assert.Equal(t, 0, m.countPost("/comment")) // no comment on create
assert.Equal(t, 0, m.countPuts()) // no update
}
func TestNotifyResolvedOnlyWithNoIssueIsNoop(t *testing.T) {
m := newMockJira(t)
retry, err := newNotifier(t, m).Notify(ctx(), alert(false))
require.NoError(t, err)
assert.False(t, retry)
assert.Equal(t, 1, m.countPost("/search/jql"))
assert.Equal(t, 0, m.countPost("/issue"))
}
func TestNotifyStillFiringUpdatesAndComments(t *testing.T) {
m := newMockJira(t)
m.searchIssues = []issue{openIssue()}
retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
require.NoError(t, err)
assert.False(t, retry)
assert.Equal(t, 0, m.countPost("/issue")) // no create
assert.Equal(t, 1, m.countPuts()) // update
assert.Equal(t, 1, m.countPost("/comment"))
assert.Equal(t, 0, m.countPost("/transitions")) // still open, no transition
// comment carries the full rich snapshot (panel + labeled body), not a one-liner.
cjs, err := json.Marshal(m.lastBody(t, "/comment"))
require.NoError(t, err)
assert.Contains(t, string(cjs), `"panel"`)
assert.Contains(t, string(cjs), "Summary:")
}
func TestNotifyResolveTransitionsToDoneAndComments(t *testing.T) {
m := newMockJira(t)
m.searchIssues = []issue{openIssue()}
m.transitions = []jiraTransition{transition("11", "To Do", "new"), transition("41", "Done", "done")}
retry, err := newNotifier(t, m).Notify(ctx(), alert(false))
require.NoError(t, err)
assert.False(t, retry)
assert.Equal(t, 1, m.countPuts()) // update
assert.Equal(t, 1, m.countPost("/transitions")) // resolve transition
assert.Equal(t, 1, m.countPost("/comment"))
}
func TestNotifyReopensDoneIssue(t *testing.T) {
m := newMockJira(t)
m.searchIssues = []issue{doneIssue()}
m.transitions = []jiraTransition{transition("11", "To Do", "new"), transition("41", "Done", "done")}
retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
require.NoError(t, err)
assert.False(t, retry)
assert.Equal(t, 1, m.countPost("/transitions")) // reopen transition
assert.Equal(t, 1, m.countPost("/comment"))
}
func TestNotifySafeSkipsWhenNoMatchingTransition(t *testing.T) {
m := newMockJira(t)
m.searchIssues = []issue{openIssue()}
m.transitions = []jiraTransition{transition("11", "To Do", "new")} // no done-category transition
retry, err := newNotifier(t, m).Notify(ctx(), alert(false))
require.NoError(t, err) // must not error
assert.False(t, retry)
assert.Equal(t, 0, m.countPost("/transitions")) // skipped
assert.Equal(t, 1, m.countPost("/comment")) // comment still posted
}
func TestNotifyPrefersOpenIssueOverRecentlyDone(t *testing.T) {
m := newMockJira(t)
open := openIssue()
open.Key = "KAN-2"
// the JQL order can put a recently-done issue first; the open one must win
m.searchIssues = []issue{doneIssue(), open}
retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
require.NoError(t, err)
assert.False(t, retry)
assert.Equal(t, 0, m.countPost("/issue")) // no duplicate create
assert.Equal(t, 0, m.countPost("/transitions")) // open issue → no reopen
assert.Equal(t, 1, m.countPuts())
assert.Equal(t, 1, m.countPost("/comment"))
m.mu.Lock()
defer m.mu.Unlock()
for _, r := range m.reqs {
if r.method == http.MethodPut || strings.HasSuffix(r.path, "/comment") {
assert.Contains(t, r.path, "KAN-2")
}
}
}
func TestNotifyRetriesOn429(t *testing.T) {
m := newMockJira(t)
m.createStatus = http.StatusTooManyRequests
retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
require.Error(t, err)
assert.True(t, retry)
}
func (m *mockJira) lastBody(t *testing.T, suffix string) map[string]any {
t.Helper()
m.mu.Lock()
defer m.mu.Unlock()
for i := len(m.reqs) - 1; i >= 0; i-- {
if m.reqs[i].method == http.MethodPost && strings.HasSuffix(m.reqs[i].path, suffix) {
return m.reqs[i].body
}
}
t.Fatalf("no POST request to %s", suffix)
return nil
}
func TestNotifyRichDescriptionPanelAndLinks(t *testing.T) {
m := newMockJira(t)
a := alert(true)
a.Labels[ruletypes.LabelRuleSource] = model.LabelValue("https://app.signoz.io/alerts?ruleId=1")
a.Annotations[ruletypes.AnnotationRelatedLogs] = model.LabelValue("https://app.signoz.io/logs")
_, err := newNotifier(t, m).Notify(ctx(), a)
require.NoError(t, err)
body := m.lastBody(t, "/issue")
js, err := json.Marshal(body)
require.NoError(t, err)
s := string(js)
assert.Contains(t, s, `"panel"`) // status panel present
assert.Contains(t, s, `"error"`) // firing → error panel
assert.Contains(t, s, "Open in SigNoz") // rule deep-link
assert.Contains(t, s, "https://app.signoz.io/alerts?ruleId=1") // rule url
assert.Contains(t, s, "View Related Logs") // related-logs deep-link
assert.Contains(t, s, "Summary:") // labeled body section
assert.Contains(t, s, "cpu high") // rendered annotation
}
func TestNotifyCustomTemplateAnnotationsOverrideDefaults(t *testing.T) {
m := newMockJira(t)
a1 := alert(true)
a1.Labels["service"] = "payment"
a1.Labels["namespace"] = "ns-one"
a1.Annotations[ruletypes.AnnotationTitleTemplate] = "High throughput for $service"
a1.Annotations[ruletypes.AnnotationBodyTemplate] = "Firing in NS: $labels.namespace"
a2 := alert(true)
a2.Labels["service"] = "payment"
a2.Labels["namespace"] = "ns-two"
a2.Annotations[ruletypes.AnnotationTitleTemplate] = "High throughput for $service"
a2.Annotations[ruletypes.AnnotationBodyTemplate] = "Firing in NS: $labels.namespace"
_, err := newNotifier(t, m).Notify(ctx(), a1, a2)
require.NoError(t, err)
body := m.lastBody(t, "/issue")
fields, ok := body["fields"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "High throughput for payment", fields["summary"])
js, err := json.Marshal(fields["description"])
require.NoError(t, err)
s := string(js)
assert.Contains(t, s, "Firing in NS: ns-one")
assert.Contains(t, s, "Firing in NS: ns-two")
// per-alert custom bodies are separated by an ADF rule divider
assert.Contains(t, s, `"rule"`)
assert.NotContains(t, s, "Summary:") // default body template not used
}
// Jira replaces labels wholesale on PUT, so the update must union in the
// labels already on the issue or user-added ones get wiped.
func TestNotifyUpdatePreservesUserAddedLabels(t *testing.T) {
m := newMockJira(t)
existing := openIssue()
existing.Fields.Labels = []string{"user-added-label", "signoz-alert"}
m.searchIssues = []issue{existing}
_, err := newNotifier(t, m).Notify(ctx(), alert(true))
require.NoError(t, err)
search := m.lastBody(t, "/search/jql")
assert.Contains(t, search["fields"], "labels")
m.mu.Lock()
var putLabels []any
for _, r := range m.reqs {
if r.method == http.MethodPut {
putLabels, _ = r.body["fields"].(map[string]any)["labels"].([]any)
}
}
m.mu.Unlock()
assert.Contains(t, putLabels, "user-added-label")
assert.Contains(t, putLabels, "signoz-alert")
assert.Equal(t, 1, strings.Count(fmt.Sprint(putLabels), "signoz-alert")) // no duplicates
// the dedup label is re-asserted
found := false
for _, l := range putLabels {
if s, ok := l.(string); ok && strings.HasPrefix(s, "ALERT{") {
found = true
}
}
assert.True(t, found)
}
func TestADFDocLen(t *testing.T) {
text := func(s string) map[string]any { return map[string]any{"type": "text", "text": s} }
para := func(children ...any) map[string]any {
return map[string]any{"type": "paragraph", "content": children}
}
cases := []struct {
name string
node any
want int
}{
{"text node", text("hello"), 7}, // 5 utf16 + 2 overhead
{"emoji counts utf16", text("🔴"), 4}, // 2 utf16 units + 2 overhead
{"paragraph wraps text", para(text("hi")), 6}, // 2 + (2+2)
{"link href counted", map[string]any{"type": "text", "text": "a", "marks": []any{map[string]any{"type": "link", "attrs": map[string]any{"href": "https://x"}}}}, 12}, // 1 + 9 href + 2
{"non-map is zero", "junk", 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
assert.Equal(t, c.want, adfDocLen(c.node))
})
}
}
// 30 fat custom bodies overflow Jira's description accounting (text + per-node
// overhead); the built doc must be shrunk under the limit, never rejected.
func TestNotifyDescriptionShrunkUnderJiraLimit(t *testing.T) {
m := newMockJira(t)
filler := strings.Repeat("This is a long runbook detail line used to inflate the alert body. ", 25)
alerts := make([]*types.Alert, 0, 30)
for i := range 30 {
a := alert(true)
a.Labels["service"] = model.LabelValue(strings.Repeat("s", 3) + string(rune('a'+i%26)))
a.Annotations[ruletypes.AnnotationTitleTemplate] = "overflow probe"
a.Annotations[ruletypes.AnnotationBodyTemplate] = model.LabelValue("**Alert in service** $labels.service\n\n" + filler)
alerts = append(alerts, a)
}
_, err := newNotifier(t, m).Notify(ctx(), alerts...)
require.NoError(t, err)
body := m.lastBody(t, "/issue")
fields, ok := body["fields"].(map[string]any)
require.True(t, ok)
desc := fields["description"]
assert.LessOrEqual(t, adfDocLen(desc), maxDescriptionLenRunes)
js, err := json.Marshal(desc)
require.NoError(t, err)
assert.Contains(t, string(js), "FIRING") // status panel survives the shrink
assert.Contains(t, string(js), "…") // body ends with the shrink marker
}
func TestFiringSearchJQLHasReopenWindow(t *testing.T) {
m := newMockJira(t)
_, err := newNotifier(t, m).Notify(ctx(), alert(true))
require.NoError(t, err)
body := m.lastBody(t, "/search/jql")
jql, ok := body["jql"].(string)
require.True(t, ok)
// newNotifier uses a 3d window → 4320 minutes.
assert.Contains(t, jql, "resolutiondate >= -4320m")
}
func TestSelectTransition(t *testing.T) {
ts := []jiraTransition{
transition("41", "Done", "done"),
transition("51", "Won't Do", "done"),
transition("11", "To Do", "new"),
}
assert.Equal(t, "41", selectTransition(ts, true, "")) // first done-category
assert.Equal(t, "51", selectTransition(ts, true, "Won't Do")) // named override
assert.Equal(t, "11", selectTransition(ts, false, "")) // first non-done
assert.Equal(t, "41", selectTransition(ts, true, "Nonexistent")) // bad override → fallback
assert.Equal(t, "", selectTransition([]jiraTransition{transition("11", "To Do", "new")}, true, "")) // none → skip
}
func TestResolveCloudID(t *testing.T) {
cases := []struct {
name string
handler http.HandlerFunc
want string
wantErr bool
wantRetry bool
}{
{
name: "success",
handler: func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/_edge/tenant_info", r.URL.Path)
_, _ = w.Write([]byte(`{"cloudId":"abc-123"}`))
},
want: "abc-123",
},
{
name: "non-200 is not retryable",
handler: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) },
wantErr: true,
},
{
name: "empty cloud id",
handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"cloudId":""}`)) },
wantErr: true,
},
{
name: "bad json",
handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`not json`)) },
wantErr: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
srv := httptest.NewServer(c.handler)
defer srv.Close()
n, err := New(&alertmanagertypes.JiraReceiverConfig{Site: srv.URL, HTTPConfig: &commoncfg.HTTPClientConfig{}}, nil, slog.New(slog.DiscardHandler), nil)
require.NoError(t, err)
got, retry, err := n.resolveCloudID(context.Background())
if c.wantErr {
assert.Error(t, err)
assert.Equal(t, c.wantRetry, retry)
return
}
require.NoError(t, err)
assert.Equal(t, c.want, got)
})
}
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2026 SigNoz, Inc.
// SPDX-License-Identifier: Apache-2.0
// Package jsmops delivers Jira Service Management Ops alerts by reusing the
// Opsgenie notifier: JSM Ops is the ex-Opsgenie alert API, so we map the JSM
// config onto config.OpsGenieConfig with APIURL pinned to the JSM native
// integration-events gateway.
package jsmops
import (
"log/slog"
"net/url"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/template"
commoncfg "github.com/prometheus/common/config"
)
const (
Integration = "jsmops"
source = "SigNoz"
)
// New builds an Opsgenie notifier pointed at the JSM native endpoint.
// advancedFeatures enables the rich treatment: HTML body and a note timeline
// (per fire and on resolve).
func New(c *alertmanagertypes.JSMOpsReceiverConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, advancedFeatures bool) (*opsgenie.Notifier, error) {
conf, err := toOpsGenieConfig(c)
if err != nil {
return nil, err
}
return opsgenie.New(conf, t, l, templater, advancedFeatures)
}
// toOpsGenieConfig maps the JSM config onto config.OpsGenieConfig with APIURL
// pinned to the JSM native gateway.
func toOpsGenieConfig(c *alertmanagertypes.JSMOpsReceiverConfig) (*config.OpsGenieConfig, error) {
apiURL, err := url.Parse(alertmanagertypes.JSMOpsAPIBaseURL)
if err != nil {
return nil, err
}
httpConfig := c.HTTPConfig
if httpConfig == nil {
httpConfig = &commoncfg.HTTPClientConfig{}
}
return &config.OpsGenieConfig{
NotifierConfig: c.NotifierConfig,
HTTPConfig: httpConfig,
APIKey: c.APIKey,
APIURL: &config.URL{URL: apiURL},
Message: c.Message,
Description: c.Description,
Priority: c.Priority,
Tags: c.Tags,
Source: source,
}, nil
}

View File

@@ -0,0 +1,41 @@
package jsmops
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
commoncfg "github.com/prometheus/common/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestToOpsGenieConfig(t *testing.T) {
c := &alertmanagertypes.JSMOpsReceiverConfig{
APIKey: "key-123",
Message: "msg",
Description: "desc",
Priority: "P1",
Tags: "signoz",
HTTPConfig: &commoncfg.HTTPClientConfig{},
}
og, err := toOpsGenieConfig(c)
require.NoError(t, err)
// Trailing slash is required: the Opsgenie notifier appends "v2/alerts..."
// with no separator, yielding /jsm/ops/integration/v2/alerts.
assert.Equal(t, "https://api.atlassian.com/jsm/ops/integration/", og.APIURL.String())
assert.Equal(t, "key-123", string(og.APIKey))
assert.Equal(t, "msg", og.Message)
assert.Equal(t, "desc", og.Description)
assert.Equal(t, "P1", og.Priority)
assert.Equal(t, "signoz", og.Tags)
assert.Equal(t, source, og.Source)
assert.Same(t, c.HTTPConfig, og.HTTPConfig)
}
func TestToOpsGenieConfigNilHTTPConfig(t *testing.T) {
og, err := toOpsGenieConfig(&alertmanagertypes.JSMOpsReceiverConfig{APIKey: "k"})
require.NoError(t, err)
assert.NotNil(t, og.HTTPConfig)
}

View File

@@ -14,6 +14,7 @@ import (
"net/http"
"os"
"strings"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
@@ -32,8 +33,13 @@ const (
Integration = "opsgenie"
)
// https://docs.opsgenie.com/docs/alert-api - 130 characters meaning runes.
const maxMessageLenRunes = 130
// https://support.atlassian.com/opsgenie/docs/alert-fields/ - message 130,
// description 15000, note 25000 runes.
const (
maxMessageLenRunes = 130
maxDescriptionLenRunes = 15000
maxNoteLenRunes = 25000
)
// Notifier implements a Notifier for OpsGenie notifications.
type Notifier struct {
@@ -43,21 +49,29 @@ type Notifier struct {
client *http.Client
retrier *notify.Retrier
templater alertmanagertypes.Templater
// advancedFeatures bundles the JSM Ops enrichments: render the default body as
// HTML (markdown -> HTML), and post a note per fire and on resolve to build an
// immutable timeline. Off for plain OpsGenie. The alert-refresh-on-refire part
// rides on the upstream UpdateAlerts config flag, set alongside this.
advancedFeatures bool
}
// New returns a new OpsGenie notifier.
func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
// New returns a new OpsGenie notifier. advancedFeatures enables the JSM Ops
// enrichments (HTML default body + a note timeline per fire and on resolve);
// pass false for plain OpsGenie.
func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, advancedFeatures bool, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
}
return &Notifier{
conf: c,
tmpl: t,
logger: l,
client: client,
retrier: &notify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
templater: templater,
conf: c,
tmpl: t,
logger: l,
client: client,
retrier: &notify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
templater: templater,
advancedFeatures: advancedFeatures,
}, nil
}
@@ -94,6 +108,30 @@ type opsGenieUpdateDescriptionMessage struct {
Description string `json:"description,omitempty"`
}
type opsGenieAddNoteMessage struct {
Note string `json:"note"`
Source string `json:"source"`
}
// noteRequest builds a POST to the alert's notes endpoint (append-only timeline).
func (n *Notifier) noteRequest(ctx context.Context, alias, note, source string) (*http.Request, error) {
noteEndpointURL := n.conf.APIURL.Copy()
noteEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/notes", alias)
q := noteEndpointURL.Query()
q.Set("identifierType", "alias")
noteEndpointURL.RawQuery = q.Encode()
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(&opsGenieAddNoteMessage{Note: note, Source: source}); err != nil {
return nil, err
}
req, err := http.NewRequest("POST", noteEndpointURL.String(), &buf)
if err != nil {
return nil, err
}
return req.WithContext(ctx), nil
}
// Notify implements the Notifier interface.
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
requests, retry, err := n.createRequests(ctx, as...)
@@ -110,12 +148,24 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body)
notify.Drain(resp)
if err != nil {
// notes are enrichment; a permanently-failed note (e.g. the first-fire
// note racing JSM's async alert create) must not fail the notification
if !shouldRetry && isNoteRequest(req) {
n.logger.WarnContext(ctx, "dropping failed note", slog.Int("status_code", resp.StatusCode), errors.Attr(err))
continue
}
return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
}
}
return true, nil
}
// isNoteRequest reports whether req targets the notes endpoint, the only one
// built by noteRequest.
func isNoteRequest(req *http.Request) bool {
return strings.HasSuffix(req.URL.Path, "/notes")
}
// Like Split but filter out empty strings.
func safeSplit(s, sep string) []string {
a := strings.Split(strings.TrimSpace(s), sep)
@@ -145,28 +195,13 @@ func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (s
}
var description string
if result.IsDefaultBody {
if result.IsDefaultBody && !n.advancedFeatures {
description = strings.Join(result.Body, "\n")
} else {
var b strings.Builder
first := true
for _, part := range result.Body {
if part == "" {
continue
}
rendered, renderErr := markdownrenderer.RenderHTML(part)
if renderErr != nil {
return "", "", renderErr
}
if !first {
b.WriteString("<hr>")
}
b.WriteString("<div>")
b.WriteString(rendered)
b.WriteString("</div>")
first = false
description, err = buildHTMLDescription(result.Body, maxDescriptionLenRunes)
if err != nil {
return "", "", err
}
description = b.String()
}
title, truncated := notify.TruncateInRunes(result.Title, maxMessageLenRunes)
@@ -174,9 +209,141 @@ func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (s
n.logger.WarnContext(ctx, "Truncated message", slog.Int("max_runes", maxMessageLenRunes))
}
// The API silently truncates over-limit descriptions, which would drop the
// trailing SigNoz link; cap here with an ellipsis instead. The HTML path is
// pre-fitted above, so this only ever cuts the plain-text default body.
description, descTruncated := notify.TruncateInRunes(description, maxDescriptionLenRunes)
if descTruncated {
n.logger.WarnContext(ctx, "Truncated description", slog.Int("max_runes", maxDescriptionLenRunes))
}
return title, description, nil
}
const (
// room reserved for the "+N more" trailer appended when parts are dropped.
descriptionTrailerReserveRunes = 80
// below this rendering budget a shrunk part carries no signal; drop it instead.
minShrinkBudgetRunes = 64
)
// buildHTMLDescription renders each markdown part to HTML (<div>-wrapped,
// <hr>-joined) while keeping the total within budget runes. An over-budget part
// is shrunk at the markdown level and re-rendered so the HTML stays well-formed;
// fully dropped parts are summarized by a "+N more" trailer.
func buildHTMLDescription(parts []string, budget int) (string, error) {
rendering := make([]string, 0, len(parts))
for _, part := range parts {
if part != "" {
rendering = append(rendering, part)
}
}
budget -= descriptionTrailerReserveRunes
var b strings.Builder
used, included := 0, 0
for _, part := range rendering {
rendered, err := markdownrenderer.RenderHTML(part)
if err != nil {
return "", err
}
overhead := len("<div></div>")
if included > 0 {
overhead += len("<hr>")
}
if used+overhead+utf8.RuneCountInString(rendered) > budget {
rendered, err = shrinkMarkdownToFit(part, budget-used-overhead)
if err != nil {
return "", err
}
if rendered == "" {
break
}
}
if included > 0 {
b.WriteString("<hr>")
}
b.WriteString("<div>")
b.WriteString(rendered)
b.WriteString("</div>")
used += overhead + utf8.RuneCountInString(rendered)
included++
}
if dropped := len(rendering) - included; dropped > 0 {
fmt.Fprintf(&b, "<hr><div><i>…and %d more alerts. Open in SigNoz for the full list.</i></div>", dropped)
}
return b.String(), nil
}
// shrinkMarkdownToFit cuts markdown until its rendered HTML fits within budget
// runes, returning "" when the budget is too small to carry anything useful.
// Only the markdown is ever cut, never the rendered HTML, so goldmark always
// emits balanced markup.
func shrinkMarkdownToFit(md string, budget int) (string, error) {
if budget < minShrinkBudgetRunes {
return "", nil
}
for range 4 {
rendered, err := markdownrenderer.RenderHTML(md)
if err != nil {
return "", err
}
renderedLen := utf8.RuneCountInString(rendered)
if renderedLen <= budget {
return rendered, nil
}
runes := []rune(md)
keep := len(runes) * budget / renderedLen * 9 / 10
if keep >= len(runes) {
keep = len(runes) - 1
}
if keep < minShrinkBudgetRunes {
return "", nil
}
md = string(runes[:keep]) + "…"
}
return "", nil
}
// prepareNote renders the same body template as plain text for a timeline note.
// JSM Ops notes render neither HTML nor markdown, so links flatten to
// "text (url)" and all markers are stripped.
func (n *Notifier) prepareNote(ctx context.Context, alerts []*types.Alert) (string, error) {
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
DefaultTitleTemplate: n.conf.Message,
DefaultBodyTemplate: n.conf.Description,
}, alerts)
if err != nil {
return "", err
}
var b strings.Builder
first := true
for _, part := range result.Body {
text, renderErr := markdownrenderer.RenderPlainText(part)
if renderErr != nil {
return "", renderErr
}
if text = strings.TrimSpace(text); text == "" {
continue
}
if !first {
b.WriteString("\n\n")
}
b.WriteString(text)
first = false
}
note, truncated := notify.TruncateInRunes(b.String(), maxNoteLenRunes)
if truncated {
n.logger.WarnContext(ctx, "Truncated note", slog.Int("max_runes", maxNoteLenRunes))
}
return note, nil
}
// Create requests for a list of alerts.
func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*http.Request, bool, error) {
key, err := notify.ExtractGroupKey(ctx)
@@ -206,6 +373,21 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h
)
switch alerts.Status() {
case model.AlertResolved:
// Post the resolved snapshot to the timeline before closing (closed alerts
// reject notes), so the note lands first.
if n.advancedFeatures {
note, err := n.prepareNote(ctx, as)
if err != nil {
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
return nil, false, err
}
noteReq, err := n.noteRequest(ctx, alias, note, tmpl(n.conf.Source))
if err != nil {
return nil, true, err
}
requests = append(requests, noteReq)
}
resolvedEndpointURL := n.conf.APIURL.Copy()
resolvedEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/close", alias)
q := resolvedEndpointURL.Query()
@@ -322,6 +504,21 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h
}
requests = append(requests, req.WithContext(ctx))
}
// Append this fire's snapshot to the timeline (every fire, including the
// first, so no datapoint is lost when the description is overwritten).
// Notes are plain text, so this uses the plain-text render, not the HTML body.
if n.advancedFeatures {
note, err := n.prepareNote(ctx, as)
if err != nil {
return nil, false, err
}
noteReq, err := n.noteRequest(ctx, alias, note, tmpl(n.conf.Source))
if err != nil {
return nil, true, err
}
requests = append(requests, noteReq)
}
}
var apiKey string

View File

@@ -6,14 +6,18 @@ package opsgenie
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
@@ -44,6 +48,7 @@ func TestOpsGenieRetry(t *testing.T) {
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
false,
)
require.NoError(t, err)
@@ -69,6 +74,7 @@ func TestOpsGenieRedactedURL(t *testing.T) {
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
false,
)
require.NoError(t, err)
@@ -96,6 +102,7 @@ func TestGettingOpsGegineApikeyFromFile(t *testing.T) {
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
false,
)
require.NoError(t, err)
@@ -216,7 +223,7 @@ func TestOpsGenie(t *testing.T) {
},
} {
t.Run(tc.title, func(t *testing.T) {
notifier, err := New(tc.cfg, tmpl, logger, newTestTemplater(tmpl))
notifier, err := New(tc.cfg, tmpl, logger, newTestTemplater(tmpl), false)
require.NoError(t, err)
ctx := context.Background()
@@ -292,7 +299,7 @@ func TestOpsGenieWithUpdate(t *testing.T) {
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
}
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), false)
alert := &types.Alert{
Alert: model.Alert{
StartsAt: time.Now(),
@@ -324,6 +331,111 @@ func TestOpsGenieWithUpdate(t *testing.T) {
assert.JSONEq(t, `{"description":"new description"}`, body2)
}
func TestOpsGenieAdvancedFeatures(t *testing.T) {
u, err := url.Parse("https://test-opsgenie-url")
require.NoError(t, err)
tmpl := test.CreateTmpl(t)
ctx := notify.WithGroupKey(context.Background(), "1")
key, _ := notify.ExtractGroupKey(ctx)
alias := key.Hash()
cfg := &config.OpsGenieConfig{
NotifierConfig: config.NotifierConfig{VSendResolved: true},
Message: `{{ .CommonLabels.Message }}`,
Description: `{{ .CommonLabels.Description }}`,
UpdateAlerts: true,
APIKey: "k",
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
}
notifier, err := New(cfg, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), true)
require.NoError(t, err)
firing := &types.Alert{Alert: model.Alert{
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
Labels: model.LabelSet{"Message": "m", "Description": "**Alert:** d [View](https://s.io/a)"},
}}
// Fire: create + update message + update description + a timeline note.
reqs, _, err := notifier.createRequests(ctx, firing)
require.NoError(t, err)
require.Len(t, reqs, 4)
assert.Equal(t, "https://test-opsgenie-url/v2/alerts", reqs[0].URL.String())
assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/notes?identifierType=alias", alias), reqs[3].URL.String())
assert.Equal(t, http.MethodPost, reqs[3].Method)
// the note body is the plain-text render: markers stripped, link flattened
var noteMsg opsGenieAddNoteMessage
require.NoError(t, json.Unmarshal([]byte(readBody(t, reqs[3])), &noteMsg))
assert.Equal(t, "Alert: d View (https://s.io/a)", noteMsg.Note)
// Resolve: note posted before the close.
resolved := &types.Alert{Alert: model.Alert{
StartsAt: time.Now().Add(-time.Hour),
EndsAt: time.Now().Add(-time.Minute),
Labels: model.LabelSet{"Message": "m", "Description": "d"},
}}
reqs, _, err = notifier.createRequests(ctx, resolved)
require.NoError(t, err)
require.Len(t, reqs, 2)
assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/notes?identifierType=alias", alias), reqs[0].URL.String())
assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/close?identifierType=alias", alias), reqs[1].URL.String())
}
func TestOpsGenieNotifyBestEffortNote(t *testing.T) {
tmpl := test.CreateTmpl(t)
ctx := notify.WithGroupKey(context.Background(), "1")
firing := &types.Alert{Alert: model.Alert{
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
Labels: model.LabelSet{"Message": "m", "Description": "d"},
}}
for _, tc := range []struct {
name string
createStatus int
noteStatus int
wantErr bool
wantRetry bool
}{
{name: "note_404_is_dropped", createStatus: http.StatusAccepted, noteStatus: http.StatusNotFound, wantErr: false, wantRetry: true},
{name: "note_429_still_retries", createStatus: http.StatusAccepted, noteStatus: http.StatusTooManyRequests, wantErr: true, wantRetry: true},
{name: "create_404_still_fails", createStatus: http.StatusNotFound, noteStatus: http.StatusAccepted, wantErr: true, wantRetry: false},
} {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/notes") {
w.WriteHeader(tc.noteStatus)
return
}
w.WriteHeader(tc.createStatus)
}))
defer srv.Close()
u, err := url.Parse(srv.URL)
require.NoError(t, err)
notifier, err := New(&config.OpsGenieConfig{
Message: `{{ .CommonLabels.Message }}`,
Description: `{{ .CommonLabels.Description }}`,
APIKey: "k",
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
}, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), true)
require.NoError(t, err)
retry, err := notifier.Notify(ctx, firing)
if tc.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
assert.Equal(t, tc.wantRetry, retry)
})
}
}
func TestOpsGenieApiKeyFile(t *testing.T) {
u, err := url.Parse("https://test-opsgenie-url")
require.NoError(t, err)
@@ -335,7 +447,7 @@ func TestOpsGenieApiKeyFile(t *testing.T) {
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
}
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), false)
require.NoError(t, err)
requests, _, err := notifierWithUpdate.createRequests(ctx)
@@ -437,6 +549,109 @@ func TestPrepareContent(t *testing.T) {
})
}
func TestShrinkMarkdownToFit(t *testing.T) {
cases := []struct {
name string
md string
budget int
wantEmpty bool
}{
{"fits untouched", "**bold** text", 1000, false},
{"shrinks to fit", strings.Repeat("lorem ipsum ", 500), 1000, false},
{"budget too small", strings.Repeat("lorem ipsum ", 500), 10, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := shrinkMarkdownToFit(c.md, c.budget)
require.NoError(t, err)
if c.wantEmpty {
assert.Empty(t, got)
return
}
assert.NotEmpty(t, got)
assert.LessOrEqual(t, utf8.RuneCountInString(got), c.budget)
assert.Equal(t, strings.Count(got, "<p>"), strings.Count(got, "</p>"))
})
}
}
func TestBuildHTMLDescriptionOverflow(t *testing.T) {
bigPart := strings.Repeat("alpha beta gamma ", 100)
cases := []struct {
name string
parts []string
budget int
wantTrailer bool
}{
{"all parts fit", []string{"**a**", "**b**"}, maxDescriptionLenRunes, false},
{"empty parts skipped", []string{"", "hello", ""}, maxDescriptionLenRunes, false},
{"overflow drops parts with trailer", repeatParts(bigPart, 12), maxDescriptionLenRunes, true},
{"single huge part shrunk without trailer", []string{strings.Repeat(bigPart, 20)}, maxDescriptionLenRunes, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := buildHTMLDescription(c.parts, c.budget)
require.NoError(t, err)
assert.LessOrEqual(t, utf8.RuneCountInString(got), c.budget)
assert.Equal(t, strings.Count(got, "<div>"), strings.Count(got, "</div>"))
assert.True(t, strings.HasSuffix(got, "</div>"))
if c.wantTrailer {
assert.Regexp(t, `…and \d+ more alerts\. Open in SigNoz for the full list\.`, got)
} else {
assert.NotContains(t, got, "more alerts")
}
})
}
}
// prepareContent end-to-end: 40 custom-template alerts overflow the description
// budget yet the posted HTML stays within limits and well-formed.
func TestPrepareContentDescriptionOverflow(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier := &Notifier{
conf: &config.OpsGenieConfig{
Message: `{{ .CommonLabels.alertname }}`,
Description: `{{ .CommonLabels.alertname }}`,
},
tmpl: tmpl,
logger: promslog.NewNopLogger(),
templater: newTestTemplater(tmpl),
advancedFeatures: true,
}
bodyTemplate := "**Alert in** $labels.namespace\n\n" + strings.Repeat("detail line for the runbook ", 30)
alerts := make([]*types.Alert, 0, 40)
for i := range 40 {
alerts = append(alerts, &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
"alertname": "overflow",
"namespace": model.LabelValue(fmt.Sprintf("ns-%d", i)),
},
Annotations: model.LabelSet{
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
})
}
_, desc, err := notifier.prepareContent(notify.WithGroupKey(context.Background(), "1"), alerts)
require.NoError(t, err)
assert.LessOrEqual(t, utf8.RuneCountInString(desc), maxDescriptionLenRunes)
assert.Equal(t, strings.Count(desc, "<div>"), strings.Count(desc, "</div>"))
assert.Regexp(t, `…and \d+ more alerts\. Open in SigNoz for the full list\.`, desc)
}
func repeatParts(part string, n int) []string {
parts := make([]string, n)
for i := range parts {
parts[i] = part
}
return parts
}
func readBody(t *testing.T, r *http.Request) string {
t.Helper()
body, err := io.ReadAll(r.Body)

View File

@@ -6,6 +6,8 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/email"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/googlechat"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/jira"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/jsmops"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/msteamsv2"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/pagerduty"
@@ -26,6 +28,8 @@ var customNotifierIntegrations = []string{
slack.Integration,
msteamsv2.Integration,
googlechat.Integration,
jira.Integration,
jsmops.Integration,
}
func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
@@ -66,7 +70,7 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp
add(pagerduty.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l, templater) })
}
for i, c := range nc.OpsGenieConfigs {
add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l, templater) })
add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l, templater, false) })
}
for i, c := range nc.SlackConfigs {
add(slack.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l, templater) })
@@ -81,6 +85,16 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp
return googlechat.New(c, tmpl, l, templater)
})
}
for i, c := range nc.JiraConfigs {
add(jira.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
return jira.New(c, tmpl, l, templater)
})
}
for i, c := range nc.JSMOpsConfigs {
add(jsmops.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
return jsmops.New(c, tmpl, l, templater, true)
})
}
if errs.Len() > 0 {
return nil, &errs

View File

@@ -332,6 +332,33 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/dashboards/system/{name}", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.GetSystemDashboard, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetSystemDashboard",
Tags: []string{"dashboard"},
Summary: "Get system dashboard",
Description: "Returns a dashboard SigNoz ships and owns, addressed by its stable definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards are read-only and upgraded through releases. The dashboard's own `name` field carries a reserved prefix that the path segment must not include.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableSystemDashboard),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: provider.systemDashboardID(),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
// Pinning mutates the calling user's pin list, not the dashboard, so it rides
// on the collection-level list permission rather than a per-dashboard check.
// The id is still extracted, for audit.
@@ -718,3 +745,23 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
return nil
}
// systemDashboardID resolves the {name} path param to the dashboard's id. Authz
// tuples and audit records are written against ids, so the name has to be
// resolved before either runs.
func (provider *provider) systemDashboardID() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
ctx := ec.Request.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return "", err
}
systemDashboard, err := provider.dashboardModule.GetSystemDashboard(ctx, valuer.MustNewUUID(claims.OrgID), mux.Vars(ec.Request)["name"])
if err != nil {
return "", err
}
return systemDashboard.ID.StringValue(), nil
})
}

View File

@@ -0,0 +1,102 @@
package signozapiserver
import (
"net/http"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/gorilla/mux"
openapi "github.com/swaggest/openapi-go"
)
// prometheusOpenAPIHandler skips the default handler wrapper: that wraps
// every response in the house envelope, and these endpoints follow
// Prometheus' wire contract, described by the prometheus package's *Schema
// types.
type prometheusOpenAPIHandler struct {
handlerFunc http.HandlerFunc
id string
summary string
params any
}
func (h *prometheusOpenAPIHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
h.handlerFunc.ServeHTTP(rw, req)
}
func (h *prometheusOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext) {
// One route serves GET and POST; operation IDs must stay unique.
id := h.id
if strings.EqualFold(opCtx.Method(), http.MethodPost) {
id += "Post"
}
opCtx.SetID(id)
opCtx.SetTags("prometheus")
opCtx.SetSummary(h.summary)
opCtx.SetDescription("Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.")
for _, scheme := range newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}) {
opCtx.AddSecurity(scheme.Name, scheme.Scopes...)
}
opCtx.AddReqStructure(h.params)
opCtx.AddRespStructure(
prometheus.SuccessResponseSchema{},
openapi.WithContentType("application/json"),
openapi.WithHTTPStatus(http.StatusOK),
)
for _, statusCode := range []int{http.StatusBadRequest, http.StatusUnprocessableEntity, http.StatusServiceUnavailable, http.StatusInternalServerError} {
opCtx.AddRespStructure(
prometheus.ErrorResponseSchema{},
openapi.WithContentType("application/json"),
openapi.WithHTTPStatus(statusCode),
)
}
// The auth middleware answers before the handler and uses the house
// envelope, not Prometheus'.
for _, statusCode := range []int{http.StatusUnauthorized, http.StatusForbidden} {
opCtx.AddRespStructure(
render.ErrorResponse{Status: render.StatusError.String(), Error: &errors.JSON{}},
openapi.WithContentType("application/json"),
openapi.WithHTTPStatus(statusCode),
)
}
}
func (h *prometheusOpenAPIHandler) ResourceDefs() []handler.ResourceDef {
return []handler.ResourceDef{handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.PromQLResources,
}}
}
func (provider *provider) addPrometheusRoutes(router *mux.Router) error {
if err := router.Handle("/prometheus/api/v1/query", &prometheusOpenAPIHandler{
handlerFunc: provider.authzMiddleware.CheckResources(provider.prometheusHandler.Query, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
id: "PrometheusQuery",
summary: "Prometheus instant query",
params: new(prometheus.QueryParamsSchema),
}).Methods(http.MethodGet, http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/prometheus/api/v1/query_range", &prometheusOpenAPIHandler{
handlerFunc: provider.authzMiddleware.CheckResources(provider.prometheusHandler.QueryRange, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
id: "PrometheusQueryRange",
summary: "Prometheus range query",
params: new(prometheus.QueryRangeParamsSchema),
}).Methods(http.MethodGet, http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -24,6 +24,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/preference"
"github.com/SigNoz/signoz/pkg/modules/promote"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/savedview"
@@ -32,6 +33,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/statsreporter"
@@ -75,11 +77,14 @@ type provider struct {
ruleStateHistoryHandler rulestatehistory.Handler
spanMapperHandler spanmapper.Handler
alertmanagerHandler alertmanager.Handler
prometheusHandler prometheus.Handler
traceDetailHandler tracedetail.Handler
rulerHandler ruler.Handler
llmPricingRuleHandler llmpricingrule.Handler
statsHandler statsreporter.Handler
savedViewHandler savedview.Handler
quickFilterModule quickfilter.Module
quickFilterHandler quickfilter.Handler
}
func NewFactory(
@@ -113,11 +118,14 @@ func NewFactory(
ruleStateHistoryHandler rulestatehistory.Handler,
spanMapperHandler spanmapper.Handler,
alertmanagerHandler alertmanager.Handler,
prometheusHandler prometheus.Handler,
llmPricingRuleHandler llmpricingrule.Handler,
traceDetailHandler tracedetail.Handler,
rulerHandler ruler.Handler,
statsHandler statsreporter.Handler,
savedViewHandler savedview.Handler,
quickFilterModule quickfilter.Module,
quickFilterHandler quickfilter.Handler,
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
return newProvider(
@@ -154,11 +162,14 @@ func NewFactory(
ruleStateHistoryHandler,
spanMapperHandler,
alertmanagerHandler,
prometheusHandler,
llmPricingRuleHandler,
traceDetailHandler,
rulerHandler,
statsHandler,
savedViewHandler,
quickFilterModule,
quickFilterHandler,
)
})
}
@@ -197,11 +208,14 @@ func newProvider(
ruleStateHistoryHandler rulestatehistory.Handler,
spanMapperHandler spanmapper.Handler,
alertmanagerHandler alertmanager.Handler,
prometheusHandler prometheus.Handler,
llmPricingRuleHandler llmpricingrule.Handler,
traceDetailHandler tracedetail.Handler,
rulerHandler ruler.Handler,
statsHandler statsreporter.Handler,
savedViewHandler savedview.Handler,
quickFilterModule quickfilter.Module,
quickFilterHandler quickfilter.Handler,
) (apiserver.APIServer, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
router := mux.NewRouter().UseEncodedPath()
@@ -239,11 +253,14 @@ func newProvider(
ruleStateHistoryHandler: ruleStateHistoryHandler,
spanMapperHandler: spanMapperHandler,
alertmanagerHandler: alertmanagerHandler,
prometheusHandler: prometheusHandler,
traceDetailHandler: traceDetailHandler,
rulerHandler: rulerHandler,
llmPricingRuleHandler: llmPricingRuleHandler,
statsHandler: statsHandler,
savedViewHandler: savedViewHandler,
quickFilterModule: quickFilterModule,
quickFilterHandler: quickFilterHandler,
}
provider.authzMiddleware = middleware.NewAuthZ(settings.Logger(), orgGetter, authzService)
@@ -340,6 +357,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addPrometheusRoutes(router); err != nil {
return err
}
if err := provider.addServiceAccountRoutes(router); err != nil {
return err
}
@@ -384,6 +405,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addQuickFilterRoutes(router); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,120 @@
package signozapiserver
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/errors"
"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/quickfiltertypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func (provider *provider) addQuickFilterRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/quick_filters", handler.New(
provider.authzMiddleware.CheckResources(provider.quickFilterHandler.ListQuickFiltersV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListQuickFilters",
Tags: []string{"quick_filter"},
Summary: "List quick filters",
Description: "Returns the org's quick filters for every source, each filter as a telemetry field key.",
Request: nil,
RequestContentType: "",
Response: new([]*quickfiltertypes.SourceFilters),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceQuickFilter.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceQuickFilter,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/quick_filters/{source}", handler.New(
provider.authzMiddleware.CheckResources(provider.quickFilterHandler.GetQuickFiltersV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetQuickFilters",
Tags: []string{"quick_filter"},
Summary: "Get a source's quick filters",
Description: "Returns the org's quick filters for one source, each filter as a telemetry field key.",
Request: nil,
RequestContentType: "",
Response: new(quickfiltertypes.SourceFilters),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceQuickFilter.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceQuickFilter,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("source"),
Selector: provider.quickFilterSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/quick_filters/{source}", handler.New(
provider.authzMiddleware.CheckResources(provider.quickFilterHandler.UpdateQuickFiltersV2, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateQuickFilters",
Tags: []string{"quick_filter"},
Summary: "Update quick filters",
Description: "Replaces the org's quick filters for the source named in the path.",
Request: new(quickfiltertypes.UpdatableQuickFilters),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceQuickFilter.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceQuickFilter,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("source"),
Selector: provider.quickFilterSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
return nil
}
func (provider *provider) quickFilterSelector(ctx context.Context, resource coretypes.Resource, source string, orgID valuer.UUID) ([]coretypes.Selector, error) {
validatedSource, err := quickfiltertypes.NewSource(source)
if err != nil {
return nil, err
}
// A source can have no stored row yet: GET serves it as empty and PUT
// creates it, so only the wildcard grant applies until the row exists.
quickFilter, err := provider.quickFilterModule.Get(ctx, orgID, validatedSource)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
return []coretypes.Selector{resource.Type().MustSelector(coretypes.WildCardSelectorString)}, nil
}
return nil, err
}
return []coretypes.Selector{
resource.Type().MustSelector(quickFilter.ID.StringValue()),
resource.Type().MustSelector(coretypes.WildCardSelectorString),
}, nil
}

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
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/instrumentation/tracehandler"
"github.com/SigNoz/signoz/pkg/version"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
@@ -108,7 +109,7 @@ func New(ctx context.Context, cfg Config, build version.Build, serviceName strin
}
// Set the global tracer provider to the sdk tracer provider so that external packages can use this
otel.SetTracerProvider(sdk.TracerProvider())
otel.SetTracerProvider(tracehandler.New(sdk.TracerProvider(), tracehandler.NewPromQL()))
return &SDK{
sdk: sdk,

View File

@@ -0,0 +1,27 @@
package tracehandler
import (
"context"
"strings"
"go.opentelemetry.io/otel/trace"
tracenoop "go.opentelemetry.io/otel/trace/noop"
)
// TODO(srikanthccv): replace with the tracer scope filter (per-scope
// "enabled") when the otel-go trace SDK ships it
// (https://github.com/open-telemetry/opentelemetry-go/issues/8411).
func NewPromQL() Wrapper {
noop := tracenoop.NewTracerProvider().Tracer("")
return WrapperFunc(func(scope string, next StartFunc) StartFunc {
if scope != "" {
return next
}
return func(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
if strings.HasPrefix(spanName, "promql") {
return noop.Start(ctx, spanName)
}
return next(ctx, spanName, opts...)
}
})
}

View File

@@ -0,0 +1,53 @@
package tracehandler
import (
"context"
"go.opentelemetry.io/otel/trace"
"go.opentelemetry.io/otel/trace/embedded"
)
// StartFunc is to trace.Tracer.Start as loghandler.LogHandlerFunc is to
// loghandler.LogHandler.
type StartFunc func(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span)
// Wrapper is an interface implemented by all trace handlers. scope is the
// instrumentation scope name of the tracer being wrapped; a wrapper that
// does not apply to a scope returns next unchanged.
type Wrapper interface {
Wrap(scope string, next StartFunc) StartFunc
}
type WrapperFunc func(scope string, next StartFunc) StartFunc
func (m WrapperFunc) Wrap(scope string, next StartFunc) StartFunc {
return m(scope, next)
}
type provider struct {
embedded.TracerProvider
base trace.TracerProvider
wrappers []Wrapper
}
func New(base trace.TracerProvider, wrappers ...Wrapper) trace.TracerProvider {
return &provider{base: base, wrappers: wrappers}
}
func (p *provider) Tracer(name string, opts ...trace.TracerOption) trace.Tracer {
base := p.base.Tracer(name, opts...)
start := StartFunc(base.Start)
for i := len(p.wrappers) - 1; i >= 0; i-- {
start = p.wrappers[i].Wrap(name, start)
}
return &tracer{start: start}
}
type tracer struct {
embedded.Tracer
start StartFunc
}
func (t *tracer) Start(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
return t.start(ctx, spanName, opts...)
}

View File

@@ -0,0 +1,87 @@
package tracehandler
import (
"context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"go.opentelemetry.io/otel/trace"
tracenoop "go.opentelemetry.io/otel/trace/noop"
)
func TestWrappersChainInOrder(t *testing.T) {
recorder := tracetest.NewSpanRecorder()
var order []string
observer := func(name string) Wrapper {
return WrapperFunc(func(_ string, next StartFunc) StartFunc {
return func(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
order = append(order, name)
return next(ctx, spanName, opts...)
}
})
}
provider := New(sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)), observer("first"), observer("second"))
_, span := provider.Tracer("test").Start(context.Background(), "op")
span.End()
assert.Equal(t, []string{"first", "second"}, order)
require.Len(t, recorder.Ended(), 1)
assert.Equal(t, "op", recorder.Ended()[0].Name())
}
func TestScopedWrapperSkipsOtherScopes(t *testing.T) {
recorder := tracetest.NewSpanRecorder()
noop := tracenoop.NewTracerProvider().Tracer("")
dropAnonymous := WrapperFunc(func(scope string, next StartFunc) StartFunc {
if scope != "" {
return next
}
return func(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
return noop.Start(ctx, spanName)
}
})
provider := New(sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)), dropAnonymous)
_, dropped := provider.Tracer("").Start(context.Background(), "anon")
dropped.End()
_, kept := provider.Tracer("named").Start(context.Background(), "op")
kept.End()
require.Len(t, recorder.Ended(), 1)
assert.Equal(t, "op", recorder.Ended()[0].Name())
}
func TestPromQL(t *testing.T) {
recorder := tracetest.NewSpanRecorder()
provider := New(sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)), NewPromQL())
ctx, root := provider.Tracer("http").Start(context.Background(), "GET /api")
engineCtx, engineSpan := provider.Tracer("").Start(ctx, "promqlInnerEval eval *promql.BinaryExpr")
assert.False(t, engineSpan.IsRecording(), "promql engine spans must not record")
assert.Equal(t, root.SpanContext().SpanID(), engineSpan.SpanContext().SpanID(), "the filtered span must keep the parent's span context")
_, child := provider.Tracer("clickhouse").Start(engineCtx, "clickhouse.query")
child.End()
_, other := provider.Tracer("").Start(ctx, "http.request")
other.End()
root.End()
var names []string
var childParent string
for _, span := range recorder.Ended() {
names = append(names, span.Name())
if span.Name() == "clickhouse.query" {
childParent = span.Parent().SpanID().String()
}
}
require.ElementsMatch(t, []string{"clickhouse.query", "http.request", "GET /api"}, names)
assert.Equal(t, root.SpanContext().SpanID().String(), childParent, "descendants of a filtered span must attach to the surrounding span")
assert.False(t, strings.HasPrefix(recorder.Ended()[0].Name(), "promql"))
}

View File

@@ -22,7 +22,7 @@ func newConfig() factory.Config {
Agent: AgentConfig{
// we will maintain the latest version of cloud integration agent from here,
// till we automate it externally or figure out a way to validate it.
Version: "v0.0.13",
Version: "v0.0.14",
},
}
}

View File

@@ -99,6 +99,14 @@ type Module interface {
DeleteView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
GetByMetricNamesV2(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error)
// ════════════════════════════════════════════════════════════════════════
// System dashboard methods
// ════════════════════════════════════════════════════════════════════════
ReconcileSystemDashboards(ctx context.Context, orgID valuer.UUID) error
GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error)
}
type Handler interface {
@@ -162,4 +170,6 @@ type Handler interface {
UpdateView(http.ResponseWriter, *http.Request)
DeleteView(http.ResponseWriter, *http.Request)
GetSystemDashboard(http.ResponseWriter, *http.Request)
}

View File

@@ -0,0 +1,17 @@
{
"version": 1,
"definition": {
"schemaVersion": "v6",
"name": "signoz---ai-o11y-overview",
"tags": [],
"spec": {
"display": {
"name": "AI Observability Overview",
"description": "Overview of LLM traffic. Panels ship in an upcoming release."
},
"variables": [],
"panels": {},
"layouts": []
}
}
}

View File

@@ -21,23 +21,25 @@ import (
)
type module struct {
store dashboardtypes.Store
settings factory.ScopedProviderSettings
analytics analytics.Analytics
orgGetter organization.Getter
queryParser queryparser.QueryParser
tagModule tag.Module
store dashboardtypes.Store
settings factory.ScopedProviderSettings
analytics analytics.Analytics
orgGetter organization.Getter
queryParser queryparser.QueryParser
tagModule tag.Module
systemDashboardRegistry dashboardtypes.SystemDashboardRegistry
}
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, tagModule tag.Module) dashboard.Module {
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard")
return &module{
store: store,
settings: scopedProviderSettings,
analytics: analytics,
orgGetter: orgGetter,
queryParser: queryParser,
tagModule: tagModule,
store: store,
settings: scopedProviderSettings,
analytics: analytics,
orgGetter: orgGetter,
queryParser: queryParser,
tagModule: tagModule,
systemDashboardRegistry: systemDashboardRegistry,
}
}

View File

@@ -3,6 +3,7 @@ package impldashboard
import (
"context"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
@@ -64,6 +65,23 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
return storableDashboard, nil
}
func (store *store) GetByName(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.StorableDashboard, error) {
storableDashboard := new(dashboardtypes.StorableDashboard)
err := store.
sqlstore.
BunDB().
NewSelect().
Model(storableDashboard).
Where("name = ?", name).
Where("org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "dashboard with name %s doesn't exist", name)
}
return storableDashboard, nil
}
// ListForUser emits the joined dashboard ⨝ user_dashboard_preference query the
// spec calls for. Aliases:
//
@@ -613,3 +631,60 @@ func (store *store) DeleteDashboardView(ctx context.Context, orgID valuer.UUID,
}
return nil
}
func (store *store) CreateSystemDashboard(ctx context.Context, storable *dashboardtypes.StorableSystemDashboard) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(storable).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, dashboardtypes.ErrCodeSystemDashboardAlreadyProvisioned, "system dashboard %s is already provisioned", storable.Name)
}
return nil
}
func (store *store) GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.StorableSystemDashboard, error) {
storable := new(dashboardtypes.StorableSystemDashboard)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(storable).
Where("org_id = ?", orgID).
Where("name = ?", name).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, dashboardtypes.ErrCodeSystemDashboardNotFound, "system dashboard %s is not provisioned", name)
}
return storable, nil
}
func (store *store) UpdateSystemDashboardVersion(ctx context.Context, orgID valuer.UUID, name string, version int) error {
result, err := store.
sqlstore.
BunDBCtx(ctx).
NewUpdate().
Model(new(dashboardtypes.StorableSystemDashboard)).
Set("version = ?", version).
Set("updated_at = ?", time.Now()).
Where("org_id = ?", orgID).
Where("name = ?", name).
Exec(ctx)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return errors.Newf(errors.TypeNotFound, dashboardtypes.ErrCodeSystemDashboardNotFound, "system dashboard %s is not provisioned", name)
}
return nil
}

View File

@@ -0,0 +1,46 @@
package impldashboard
import (
"embed"
"io/fs"
"path"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
)
const definitionsRoot = "fs/definitions"
//go:embed fs/definitions/*.json
var definitionFiles embed.FS
// NewSystemDashboardRegistry parses every embedded definition. Definitions are
// build-time assets validated by a test, so a failure here means the binary
// shipped broken JSON.
func NewSystemDashboardRegistry() (dashboardtypes.SystemDashboardRegistry, error) {
entries, err := fs.ReadDir(definitionFiles, definitionsRoot)
if err != nil {
return dashboardtypes.SystemDashboardRegistry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read system dashboard definitions")
}
definitions := make([]dashboardtypes.SystemDashboardDefinition, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
file := path.Join(definitionsRoot, entry.Name())
raw, err := definitionFiles.ReadFile(file)
if err != nil {
return dashboardtypes.SystemDashboardRegistry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read %s", file)
}
definition, err := dashboardtypes.NewSystemDashboardDefinition(raw)
if err != nil {
return dashboardtypes.SystemDashboardRegistry{}, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't parse %s", file)
}
definitions = append(definitions, definition)
}
return dashboardtypes.NewSystemDashboardRegistry(definitions)
}

View File

@@ -0,0 +1,81 @@
package impldashboard
import (
"context"
"log/slog"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
)
const reconcileRetryInterval = 30 * time.Second
type service struct {
settings factory.ScopedProviderSettings
module dashboard.Module
orgGetter organization.Getter
stopC chan struct{}
healthyC chan struct{}
}
// NewService reconciles every org's system dashboards once at startup. Orgs
// created later are reconciled by the organization setter instead.
func NewService(providerSettings factory.ProviderSettings, module dashboard.Module, orgGetter organization.Getter) factory.Service {
return &service{
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"),
module: module,
orgGetter: orgGetter,
stopC: make(chan struct{}),
healthyC: make(chan struct{}),
}
}
func (service *service) Start(ctx context.Context) error {
ticker := time.NewTicker(reconcileRetryInterval)
defer ticker.Stop()
for {
err := service.reconcile(ctx)
if err == nil {
close(service.healthyC)
<-service.stopC
return nil
}
service.settings.Logger().WarnContext(ctx, "system dashboard reconciliation failed, retrying", errors.Attr(err))
select {
case <-service.stopC:
return nil
case <-ticker.C:
}
}
}
func (service *service) Healthy() <-chan struct{} {
return service.healthyC
}
func (service *service) Stop(_ context.Context) error {
close(service.stopC)
return nil
}
func (service *service) reconcile(ctx context.Context) error {
orgs, err := service.orgGetter.ListByOwnedKeyRange(ctx)
if err != nil {
return err
}
for _, org := range orgs {
if err := service.module.ReconcileSystemDashboards(ctx, org.ID); err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't reconcile system dashboards for org %s", org.ID.StringValue())
}
}
service.settings.Logger().InfoContext(ctx, "system dashboard reconciliation completed", slog.Int("orgs", len(orgs)))
return nil
}

View File

@@ -502,3 +502,28 @@ func (handler *handler) GetPublicWidgetQueryRangeV2(rw http.ResponseWriter, r *h
render.Success(rw, http.StatusOK, queryRangeResults)
}
func (handler *handler) GetSystemDashboard(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
}
name := mux.Vars(r)["name"]
if name == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "name is missing in the path"))
return
}
systemDashboard, err := handler.module.GetSystemDashboard(ctx, valuer.MustNewUUID(claims.OrgID), name)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, systemDashboard.ToGettableSystemDashboard())
}

View File

@@ -2,6 +2,8 @@ package impldashboard
import (
"context"
"log/slog"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/transition"
@@ -19,9 +21,12 @@ func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy stri
return nil, err
}
dashboard := postable.NewDashboardV2(orgID, createdBy, source)
dashboard, err := postable.NewDashboardV2(orgID, createdBy, source)
if err != nil {
return nil, err
}
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
err = m.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := m.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, dashboard.ID, postable.Tags)
if err != nil {
return err
@@ -120,6 +125,20 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return storable.ToDashboardV2(tags)
}
func (module *module) getByNameV2(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
storable, err := module.store.GetByName(ctx, orgID, name)
if err != nil {
return nil, err
}
tags, err := module.tagModule.ListForResource(ctx, orgID, coretypes.KindDashboard, storable.ID)
if err != nil {
return nil, err
}
return storable.ToDashboardV2(tags)
}
// MigrateV2 retries the v1→v2 migration on a dashboard still stored as v1 (one the
// bulk 103 migration skipped or failed). Idempotent: an already-v2 one is unchanged.
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
@@ -179,13 +198,33 @@ func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer
return nil, err
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updatable.Tags)
return module.updateV2(ctx, orgID, existing, updatedBy, updatable, existing.Update)
}
// updateUnsafeV2 updates a dashboard bypassing the guards. Intended for internal system callers.
func (module *module) updateUnsafeV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
if err := updatable.Validate(); err != nil {
return nil, err
}
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
return module.updateV2(ctx, orgID, existing, updatedBy, updatable, existing.UpdateUnsafe)
}
// apply is existing.Update or existing.UpdateUnsafe, so the gated path keeps its
// in-transaction checks and only updateUnsafeV2 skips them.
func (module *module) updateV2(ctx context.Context, orgID valuer.UUID, existing *dashboardtypes.DashboardV2, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2, apply func(dashboardtypes.UpdatableDashboardV2, string, []*tagtypes.Tag) error) (*dashboardtypes.DashboardV2, error) {
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, existing.ID, updatable.Tags)
if err != nil {
return err
}
err = existing.Update(updatable, updatedBy, resolvedTags)
err = apply(updatable, updatedBy, resolvedTags)
if err != nil {
return err
}
@@ -296,3 +335,98 @@ func (module *module) UnpinV2(ctx context.Context, orgID valuer.UUID, userID val
func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
return module.store.DeletePreferencesForUser(ctx, orgID, userID)
}
func (m *module) ReconcileSystemDashboards(ctx context.Context, orgID valuer.UUID) error {
for _, definition := range m.systemDashboardRegistry.List() {
if err := m.reconcileSystemDashboard(ctx, orgID, definition); err != nil {
return err
}
}
return nil
}
func (m *module) reconcileSystemDashboard(ctx context.Context, orgID valuer.UUID, definition dashboardtypes.SystemDashboardDefinition) error {
existing, err := m.getByNameV2(ctx, orgID, definition.Name())
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
return err
}
return m.provisionSystemDashboard(ctx, orgID, definition)
}
state, err := m.store.GetSystemDashboard(ctx, orgID, definition.Name())
if err != nil {
return err
}
// Only ever move forward: a downgrade must not rewrite the newer content.
if state.Version >= definition.Version {
return nil
}
return m.upgradeSystemDashboard(ctx, orgID, existing.ID, definition)
}
// provisionSystemDashboard creates the dashboard and its state row in one transaction,
// so a system dashboard can never exist without the version it was provisioned at.
// A concurrent provisioner (another replica, or the org-creation hook racing the
// startup sweep) loses on the state row's unique (org_id, name) index and rolls back.
func (m *module) provisionSystemDashboard(ctx context.Context, orgID valuer.UUID, definition dashboardtypes.SystemDashboardDefinition) error {
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
created, err := m.CreateV2(
ctx,
orgID,
dashboardtypes.ProvisionerIdentity,
valuer.UUID{},
dashboardtypes.SourceSystem,
definition.Dashboard,
)
if err != nil {
return err
}
return m.store.CreateSystemDashboard(ctx, dashboardtypes.NewStorableSystemDashboard(orgID, created.ID, definition.Name(), definition.Version))
})
if err != nil {
if errors.Ast(err, errors.TypeAlreadyExists) {
m.settings.Logger().DebugContext(ctx, "system dashboard already provisioned concurrently", slog.String("name", definition.Name()), slog.String("org_id", orgID.StringValue()))
return nil
}
return err
}
m.settings.Logger().InfoContext(ctx, "provisioned system dashboard", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
return nil
}
func (m *module) upgradeSystemDashboard(ctx context.Context, orgID valuer.UUID, id valuer.UUID, definition dashboardtypes.SystemDashboardDefinition) error {
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
if _, err := m.updateUnsafeV2(ctx, orgID, id, dashboardtypes.ProvisionerIdentity, definition.ToUpdatable()); err != nil {
return err
}
return m.store.UpdateSystemDashboardVersion(ctx, orgID, definition.Name(), definition.Version)
})
if err != nil {
return err
}
m.settings.Logger().InfoContext(ctx, "upgraded system dashboard", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
return nil
}
func (m *module) GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
if strings.HasPrefix(name, dashboardtypes.SystemDashboardNamePrefix) {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "name must not carry the %q prefix", dashboardtypes.SystemDashboardNamePrefix)
}
existing, err := m.getByNameV2(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+name)
if err != nil {
return nil, err
}
if err := existing.ErrIfNotSystem(); err != nil {
return nil, err
}
return existing, nil
}

View File

@@ -0,0 +1,191 @@
package impldashboard
import (
"context"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/analytics/analyticstest"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testDashboardName = "test-overview"
func newTestSQLStore(t *testing.T) sqlstore.SQLStore {
t.Helper()
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
Provider: "sqlite",
Connection: sqlstore.ConnectionConfig{MaxOpenConns: 10},
Sqlite: sqlstore.SqliteConfig{
Path: filepath.Join(t.TempDir(), "test.db"),
Mode: "wal",
BusyTimeout: 5 * time.Second,
TransactionMode: "deferred",
},
})
require.NoError(t, err)
for _, model := range []any{
(*dashboardtypes.StorableDashboard)(nil),
(*tagtypes.Tag)(nil),
(*tagtypes.TagRelation)(nil),
(*dashboardtypes.StorableSystemDashboard)(nil),
} {
_, err := store.BunDB().NewCreateTable().Model(model).IfNotExists().Exec(context.Background())
require.NoError(t, err)
}
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_system_dashboard_org_name ON system_dashboard (org_id, name)`)
require.NoError(t, err)
return store
}
func newTestModule(t *testing.T, sqlStore sqlstore.SQLStore, definitions ...dashboardtypes.SystemDashboardDefinition) *module {
t.Helper()
registry, err := dashboardtypes.NewSystemDashboardRegistry(definitions)
require.NoError(t, err)
providerSettings := factorytest.NewSettings()
return NewModule(
NewStore(sqlStore),
providerSettings,
analyticstest.New(),
nil,
queryparser.New(providerSettings),
impltag.NewModule(impltag.NewStore(sqlStore)),
registry,
).(*module)
}
func newTestDefinition(t *testing.T, version int, displayName string) dashboardtypes.SystemDashboardDefinition {
t.Helper()
raw := `{
"version": ` + strconv.Itoa(version) + `,
"definition": {
"schemaVersion": "` + dashboardtypes.SchemaVersion + `",
"name": "` + dashboardtypes.SystemDashboardNamePrefix + testDashboardName + `",
"tags": [],
"spec": {"display": {"name": "` + displayName + `"}, "variables": [], "panels": {}, "layouts": []}
}
}`
definition, err := dashboardtypes.NewSystemDashboardDefinition([]byte(raw))
require.NoError(t, err)
return definition
}
func TestReconcileProvisionsThenUpgrades(t *testing.T) {
ctx := context.Background()
sqlStore := newTestSQLStore(t)
orgID := valuer.GenerateUUID()
dashboardModule := newTestModule(t, sqlStore, newTestDefinition(t, 1, "v1"))
require.NoError(t, dashboardModule.ReconcileSystemDashboards(ctx, orgID))
provisioned, err := dashboardModule.GetSystemDashboard(ctx, orgID, testDashboardName)
require.NoError(t, err)
assert.Equal(t, dashboardtypes.SourceSystem, provisioned.Source)
assert.Equal(t, dashboardtypes.ProvisionerIdentity, provisioned.CreatedBy)
assert.Equal(t, "v1", provisioned.Spec.Display.Name)
assert.Equal(t, 1, stateVersion(t, dashboardModule, ctx, orgID))
// Reconciling the same version again is a no-op.
require.NoError(t, dashboardModule.ReconcileSystemDashboards(ctx, orgID))
unchanged, err := dashboardModule.GetSystemDashboard(ctx, orgID, testDashboardName)
require.NoError(t, err)
assert.Equal(t, provisioned.UpdatedAt, unchanged.UpdatedAt)
// An unmodified copy is upgraded in place, keeping its id.
upgradingModule := newTestModule(t, sqlStore, newTestDefinition(t, 2, "v2"))
require.NoError(t, upgradingModule.ReconcileSystemDashboards(ctx, orgID))
upgraded, err := upgradingModule.GetSystemDashboard(ctx, orgID, testDashboardName)
require.NoError(t, err)
assert.Equal(t, provisioned.ID, upgraded.ID)
assert.Equal(t, "v2", upgraded.Spec.Display.Name)
assert.Equal(t, 2, stateVersion(t, upgradingModule, ctx, orgID))
}
func stateVersion(t *testing.T, module *module, ctx context.Context, orgID valuer.UUID) int {
t.Helper()
state, err := module.store.GetSystemDashboard(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+testDashboardName)
require.NoError(t, err)
return state.Version
}
func TestSystemDashboardsAreImmutableToUsers(t *testing.T) {
ctx := context.Background()
sqlStore := newTestSQLStore(t)
orgID := valuer.GenerateUUID()
dashboardModule := newTestModule(t, sqlStore, newTestDefinition(t, 1, "v1"))
require.NoError(t, dashboardModule.ReconcileSystemDashboards(ctx, orgID))
provisioned, err := dashboardModule.GetSystemDashboard(ctx, orgID, testDashboardName)
require.NoError(t, err)
_, err = dashboardModule.UpdateV2(ctx, orgID, provisioned.ID, "user@signoz.io", newTestDefinition(t, 1, "edited").ToUpdatable())
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot be modified")
}
func TestReconcileDoesNotDowngrade(t *testing.T) {
ctx := context.Background()
sqlStore := newTestSQLStore(t)
orgID := valuer.GenerateUUID()
newerModule := newTestModule(t, sqlStore, newTestDefinition(t, 3, "v3"))
require.NoError(t, newerModule.ReconcileSystemDashboards(ctx, orgID))
olderModule := newTestModule(t, sqlStore, newTestDefinition(t, 2, "v2"))
require.NoError(t, olderModule.ReconcileSystemDashboards(ctx, orgID))
got, err := newerModule.GetSystemDashboard(ctx, orgID, testDashboardName)
require.NoError(t, err)
assert.Equal(t, "v3", got.Spec.Display.Name)
assert.Equal(t, 3, stateVersion(t, newerModule, ctx, orgID))
}
func TestGetRejectsANonSystemDashboard(t *testing.T) {
ctx := context.Background()
sqlStore := newTestSQLStore(t)
orgID := valuer.GenerateUUID()
dashboardModule := newTestModule(t, sqlStore)
var postable dashboardtypes.PostableDashboardV2
require.NoError(t, postable.UnmarshalJSON([]byte(`{
"schemaVersion": "`+dashboardtypes.SchemaVersion+`",
"name": "a-user-dashboard",
"tags": [],
"spec": {"display": {"name": "user"}, "variables": [], "panels": {}, "layouts": []}
}`)))
_, err := dashboardModule.CreateV2(ctx, orgID, "user@signoz.io", valuer.GenerateUUID(), dashboardtypes.SourceUser, postable)
require.NoError(t, err)
// The server-side prefix makes user names structurally unreachable here.
_, err = dashboardModule.GetSystemDashboard(ctx, orgID, "a-user-dashboard")
require.Error(t, err)
_, err = dashboardModule.GetSystemDashboard(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+testDashboardName)
require.Error(t, err)
assert.Contains(t, err.Error(), "must not carry")
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
"github.com/SigNoz/signoz/pkg/types"
@@ -14,10 +15,11 @@ type setter struct {
store types.OrganizationStore
alertmanager alertmanager.Alertmanager
quickfilter quickfilter.Module
dashboard dashboard.Module
}
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module) organization.Setter {
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter}
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module, dashboard dashboard.Module) organization.Setter {
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter, dashboard: dashboard}
}
func (module *setter) Create(ctx context.Context, organization *types.Organization, createManagedRoles func(context.Context, valuer.UUID) error) error {
@@ -37,6 +39,10 @@ func (module *setter) Create(ctx context.Context, organization *types.Organizati
return err
}
if err := module.dashboard.ReconcileSystemDashboards(ctx, organization.ID); err != nil {
return err
}
return nil
}

View File

@@ -4,10 +4,13 @@ import (
"encoding/json"
"net/http"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -20,6 +23,13 @@ func NewHandler(module quickfilter.Module) quickfilter.Handler {
return &handler{module: module}
}
// legacySourceFilters is the v1 API shape: filters as v3 attribute keys,
// with the source still spelled "signal" on the wire.
type legacySourceFilters struct {
Source quickfiltertypes.Source `json:"signal"`
Filters []v3.AttributeKey `json:"filters"`
}
func (handler *handler) GetQuickFilters(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
@@ -27,13 +37,41 @@ func (handler *handler) GetQuickFilters(rw http.ResponseWriter, r *http.Request)
return
}
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID))
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), quickfiltertypes.Source{})
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, filters)
legacyFilters := make([]*legacySourceFilters, 0, len(filters))
for _, sourceFilters := range filters {
legacyFilters = append(legacyFilters, newLegacySourceFilters(sourceFilters))
}
render.Success(rw, http.StatusOK, legacyFilters)
}
func (handler *handler) GetSourceFilters(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
}
source := mux.Vars(r)["signal"]
validatedSource, err := quickfiltertypes.NewSource(source)
if err != nil {
render.Error(rw, err)
return
}
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), validatedSource)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, newLegacySourceFilters(handler.sourceFiltersOrEmpty(filters, validatedSource)))
}
func (handler *handler) UpdateQuickFilters(rw http.ResponseWriter, r *http.Request) {
@@ -43,14 +81,19 @@ func (handler *handler) UpdateQuickFilters(rw http.ResponseWriter, r *http.Reque
return
}
var req quickfiltertypes.UpdatableQuickFilters
decodeErr := json.NewDecoder(r.Body).Decode(&req)
if decodeErr != nil {
render.Error(rw, decodeErr)
var req legacySourceFilters
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
err = handler.module.UpdateQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), req.Signal, req.Filters)
fieldKeys, err := newTelemetryFieldKeysFromLegacy(req.Source, req.Filters)
if err != nil {
render.Error(rw, err)
return
}
err = handler.module.UpsertQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), req.Source, fieldKeys)
if err != nil {
render.Error(rw, err)
return
@@ -59,21 +102,14 @@ func (handler *handler) UpdateQuickFilters(rw http.ResponseWriter, r *http.Reque
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) GetSignalFilters(rw http.ResponseWriter, r *http.Request) {
func (handler *handler) ListQuickFiltersV2(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
}
signal := mux.Vars(r)["signal"]
validatedSignal, err := quickfiltertypes.NewSignal(signal)
if err != nil {
render.Error(rw, err)
return
}
filters, err := handler.module.GetSignalFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), validatedSignal)
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), quickfiltertypes.Source{})
if err != nil {
render.Error(rw, err)
return
@@ -81,3 +117,141 @@ func (handler *handler) GetSignalFilters(rw http.ResponseWriter, r *http.Request
render.Success(rw, http.StatusOK, filters)
}
func (handler *handler) UpdateQuickFiltersV2(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
}
source := mux.Vars(r)["source"]
validatedSource, err := quickfiltertypes.NewSource(source)
if err != nil {
render.Error(rw, err)
return
}
var req quickfiltertypes.UpdatableQuickFilters
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
err = handler.module.UpsertQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), validatedSource, req.Filters)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) GetQuickFiltersV2(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
}
source := mux.Vars(r)["source"]
validatedSource, err := quickfiltertypes.NewSource(source)
if err != nil {
render.Error(rw, err)
return
}
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), validatedSource)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, handler.sourceFiltersOrEmpty(filters, validatedSource))
}
// sourceFiltersOrEmpty keeps the single-source response contract: a source
// with no stored filters is served as an empty filter list, not an error.
func (handler *handler) sourceFiltersOrEmpty(filters []*quickfiltertypes.SourceFilters, source quickfiltertypes.Source) *quickfiltertypes.SourceFilters {
if len(filters) == 0 {
return quickfiltertypes.NewSourceFiltersFromSource(source)
}
return filters[0]
}
// newTelemetryFieldKeysFromLegacy converts a v1 write payload with the same
// normalizations as the storage migration: alias contexts, numerics to number.
// The v1 shape carries no per filter signal, so meter keys get it restored.
func newTelemetryFieldKeysFromLegacy(source quickfiltertypes.Source, filters []v3.AttributeKey) ([]telemetrytypes.TelemetryFieldKey, error) {
var fieldSignal telemetrytypes.Signal
if source == quickfiltertypes.SourceMeter {
fieldSignal = telemetrytypes.SignalMetrics
}
fieldKeys := make([]telemetrytypes.TelemetryFieldKey, 0, len(filters))
for _, filter := range filters {
if err := filter.Validate(); err != nil {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid filter: %v", err)
}
fieldContext, ok := telemetrytypes.FieldContextFromText(string(filter.Type))
if !ok {
fieldContext = telemetrytypes.FieldContextUnspecified
}
var fieldDataType telemetrytypes.FieldDataType
if err := fieldDataType.Scan(string(filter.DataType)); err != nil {
fieldDataType = telemetrytypes.FieldDataTypeUnspecified
}
if fieldDataType == telemetrytypes.FieldDataTypeInt64 {
fieldDataType = telemetrytypes.FieldDataTypeNumber
}
fieldKeys = append(fieldKeys, telemetrytypes.TelemetryFieldKey{
Name: filter.Key,
Signal: fieldSignal,
FieldContext: fieldContext,
FieldDataType: fieldDataType,
})
}
return fieldKeys, nil
}
// newLegacySourceFilters renders stored telemetry field keys
// back into the v1 shape, restoring the legacy spellings v1 clients expect.
func newLegacySourceFilters(sourceFilters *quickfiltertypes.SourceFilters) *legacySourceFilters {
filters := make([]v3.AttributeKey, 0, len(sourceFilters.Filters))
for _, fieldKey := range sourceFilters.Filters {
// Only tag and resource exist in the v3 enum; other contexts render as
// unspecified so v1 clients never see spellings their queries can't use.
var attributeType v3.AttributeKeyType
switch fieldKey.FieldContext {
case telemetrytypes.FieldContextAttribute:
attributeType = v3.AttributeKeyTypeTag
case telemetrytypes.FieldContextResource:
attributeType = v3.AttributeKeyTypeResource
default:
attributeType = v3.AttributeKeyTypeUnspecified
}
var dataType v3.AttributeKeyDataType
switch fieldKey.FieldDataType {
case telemetrytypes.FieldDataTypeNumber:
dataType = v3.AttributeKeyDataTypeFloat64
default:
dataType = v3.AttributeKeyDataType(fieldKey.FieldDataType.StringValue())
}
filters = append(filters, v3.AttributeKey{
Key: fieldKey.Name,
Type: attributeType,
DataType: dataType,
})
}
return &legacySourceFilters{
Source: sourceFilters.Source,
Filters: filters,
}
}

View File

@@ -0,0 +1,62 @@
package implquickfilter
import (
"testing"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewTelemetryFieldKeysFromLegacy(t *testing.T) {
fieldKeys, err := newTelemetryFieldKeysFromLegacy(quickfiltertypes.SourceTraces, []v3.AttributeKey{
{Key: "service.name", Type: v3.AttributeKeyTypeResource, DataType: v3.AttributeKeyDataTypeString},
{Key: "http.method", Type: v3.AttributeKeyTypeTag, DataType: v3.AttributeKeyDataTypeString},
{Key: "duration_nano", Type: v3.AttributeKeyTypeTag, DataType: v3.AttributeKeyDataTypeFloat64},
{Key: "code_line", Type: v3.AttributeKeyTypeTag, DataType: v3.AttributeKeyDataTypeInt64},
})
require.NoError(t, err)
require.Len(t, fieldKeys, 4)
assert.Equal(t, telemetrytypes.TelemetryFieldKey{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString}, fieldKeys[0])
assert.Equal(t, telemetrytypes.FieldContextAttribute, fieldKeys[1].FieldContext)
assert.Equal(t, telemetrytypes.FieldDataTypeNumber, fieldKeys[2].FieldDataType)
assert.Equal(t, telemetrytypes.FieldDataTypeNumber, fieldKeys[3].FieldDataType)
t.Run("meter writes restore the per-filter telemetry signal", func(t *testing.T) {
fieldKeys, err := newTelemetryFieldKeysFromLegacy(quickfiltertypes.SourceMeter, []v3.AttributeKey{
{Key: "host.name", DataType: v3.AttributeKeyDataTypeString},
})
require.NoError(t, err)
require.Len(t, fieldKeys, 1)
assert.Equal(t, telemetrytypes.SignalMetrics, fieldKeys[0].Signal)
})
t.Run("rejects a filter without a key", func(t *testing.T) {
_, err := newTelemetryFieldKeysFromLegacy(quickfiltertypes.SourceTraces, []v3.AttributeKey{{DataType: v3.AttributeKeyDataTypeString}})
require.Error(t, err)
})
}
func TestNewLegacySourceFilters(t *testing.T) {
legacy := newLegacySourceFilters(&quickfiltertypes.SourceFilters{
Source: quickfiltertypes.SourceLogs,
Filters: []telemetrytypes.TelemetryFieldKey{
{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "http.method", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "duration_nano", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeNumber},
{Name: "severity_text", FieldContext: telemetrytypes.FieldContextLog, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "host.name", Signal: telemetrytypes.SignalMetrics},
},
})
assert.Equal(t, quickfiltertypes.SourceLogs, legacy.Source)
require.Len(t, legacy.Filters, 5)
assert.Equal(t, v3.AttributeKey{Key: "service.name", Type: v3.AttributeKeyTypeResource, DataType: v3.AttributeKeyDataTypeString}, legacy.Filters[0])
assert.Equal(t, v3.AttributeKeyTypeTag, legacy.Filters[1].Type)
assert.Equal(t, v3.AttributeKeyDataTypeFloat64, legacy.Filters[2].DataType)
assert.Equal(t, v3.AttributeKeyTypeUnspecified, legacy.Filters[3].Type, "contexts outside the v3 enum must render as unspecified")
assert.Equal(t, v3.AttributeKey{Key: "host.name"}, legacy.Filters[4])
}

View File

@@ -2,12 +2,11 @@ package implquickfilter
import (
"context"
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -19,91 +18,54 @@ func NewModule(store quickfiltertypes.QuickFilterStore) quickfilter.Module {
return &module{store: store}
}
// GetQuickFilters returns all quick filters for an organization.
func (module *module) GetQuickFilters(ctx context.Context, orgID valuer.UUID) ([]*quickfiltertypes.SignalFilters, error) {
storedFilters, err := module.store.Get(ctx, orgID)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error fetching organization filters")
}
result := make([]*quickfiltertypes.SignalFilters, 0, len(storedFilters))
for _, storedFilter := range storedFilters {
signalFilter, err := quickfiltertypes.NewSignalFilterFromStorableQuickFilter(storedFilter)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error processing filter for signal: %s", storedFilter.Signal)
}
result = append(result, signalFilter)
}
return result, nil
func (module *module) Get(ctx context.Context, orgID valuer.UUID, source quickfiltertypes.Source) (*quickfiltertypes.StorableQuickFilter, error) {
return module.store.GetBySource(ctx, orgID, source.StringValue())
}
// GetSignalFilters returns quick filters for a specific signal in an organization.
func (m *module) GetSignalFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal) (*quickfiltertypes.SignalFilters, error) {
storedFilter, err := m.store.GetBySignal(ctx, orgID, signal.StringValue())
// GetQuickFilters returns quick filters for a source, or for every source when source is zero.
func (module *module) GetQuickFilters(ctx context.Context, orgID valuer.UUID, source quickfiltertypes.Source) ([]*quickfiltertypes.SourceFilters, error) {
if source.IsZero() {
storedFilters, err := module.store.Get(ctx, orgID)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error fetching organization filters")
}
result := make([]*quickfiltertypes.SourceFilters, 0, len(storedFilters))
for _, storedFilter := range storedFilters {
sourceFilter, err := quickfiltertypes.NewSourceFilterFromStorableQuickFilter(storedFilter)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error processing filter for source: %s", storedFilter.Source)
}
result = append(result, sourceFilter)
}
return result, nil
}
storedFilter, err := module.store.GetBySource(ctx, orgID, source.StringValue())
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
return []*quickfiltertypes.SourceFilters{}, nil
}
return nil, err
}
// If no filter exists for this signal, return empty filters with the requested signal
if storedFilter == nil {
return &quickfiltertypes.SignalFilters{
Signal: signal,
Filters: []v3.AttributeKey{},
}, nil
}
// Convert stored filter to signal filter
signalFilter, err := quickfiltertypes.NewSignalFilterFromStorableQuickFilter(storedFilter)
sourceFilter, err := quickfiltertypes.NewSourceFilterFromStorableQuickFilter(storedFilter)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error processing filter for signal: %s", storedFilter.Signal)
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error processing filter for source: %s", storedFilter.Source)
}
return signalFilter, nil
return []*quickfiltertypes.SourceFilters{sourceFilter}, nil
}
// UpdateQuickFilters updates quick filters for a specific signal in an organization.
func (module *module) UpdateQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []v3.AttributeKey) error {
// Validate each filter
for _, filter := range filters {
if err := filter.Validate(); err != nil {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid filter: %v", err)
}
}
// Marshal filters to JSON
filterJSON, err := json.Marshal(filters)
// UpsertQuickFilters replaces quick filters for a specific source in an organization, creating them if absent.
func (module *module) UpsertQuickFilters(ctx context.Context, orgID valuer.UUID, source quickfiltertypes.Source, filters []telemetrytypes.TelemetryFieldKey) error {
filter, err := quickfiltertypes.NewStorableQuickFilter(orgID, source, filters)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error marshalling filters")
}
// Check if filter exists
existingFilter, err := module.store.GetBySignal(ctx, orgID, signal.StringValue())
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error checking existing filters")
}
var filter *quickfiltertypes.StorableQuickFilter
if existingFilter != nil {
// Update in place
if err := existingFilter.Update(filterJSON); err != nil {
return errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "error updating existing filter")
}
filter = existingFilter
} else {
// Create new
filter, err = quickfiltertypes.NewStorableQuickFilter(orgID, signal, filterJSON)
if err != nil {
return errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "error creating new filter")
}
}
// Persist filter
if err := module.store.Upsert(ctx, filter); err != nil {
return err
}
return nil
return module.store.Upsert(ctx, filter)
}
func (module *module) SetDefaultConfig(ctx context.Context, orgID valuer.UUID) error {

View File

@@ -26,7 +26,7 @@ func (s *store) Get(ctx context.Context, orgID valuer.UUID) ([]*quickfiltertypes
NewSelect().
Model(&filters).
Where("org_id = ?", orgID).
Order("signal ASC").
Order("source ASC").
Scan(ctx)
if err != nil {
@@ -36,7 +36,7 @@ func (s *store) Get(ctx context.Context, orgID valuer.UUID) ([]*quickfiltertypes
return filters, nil
}
func (s *store) GetBySignal(ctx context.Context, orgID valuer.UUID, signal string) (*quickfiltertypes.StorableQuickFilter, error) {
func (s *store) GetBySource(ctx context.Context, orgID valuer.UUID, source string) (*quickfiltertypes.StorableQuickFilter, error) {
filter := new(quickfiltertypes.StorableQuickFilter)
err := s.store.
@@ -44,12 +44,12 @@ func (s *store) GetBySignal(ctx context.Context, orgID valuer.UUID, signal strin
NewSelect().
Model(filter).
Where("org_id = ?", orgID).
Where("signal = ?", signal).
Where("source = ?", source).
Scan(ctx)
if err != nil {
if err == sql.ErrNoRows {
return nil, s.store.WrapNotFoundErrf(err, errors.CodeNotFound, "No rows found for org_id: "+orgID.StringValue()+" signal: "+signal)
return nil, s.store.WrapNotFoundErrf(err, errors.CodeNotFound, "No rows found for org_id: "+orgID.StringValue()+" source: "+source)
}
return nil, err
}
@@ -62,7 +62,7 @@ func (s *store) Upsert(ctx context.Context, filter *quickfiltertypes.StorableQui
BunDB().
NewInsert().
Model(filter).
On("CONFLICT (id) DO UPDATE").
On("CONFLICT (org_id, source) DO UPDATE").
Set("filter = EXCLUDED.filter").
Set("updated_at = EXCLUDED.updated_at").
Exec(ctx)
@@ -78,7 +78,7 @@ func (s *store) Create(ctx context.Context, filters []*quickfiltertypes.Storable
BunDBCtx(ctx).
NewInsert().
Model(&filters).
On("CONFLICT (org_id, signal) DO NOTHING").
On("CONFLICT (org_id, source) DO NOTHING").
Exec(ctx)
if err != nil {

View File

@@ -4,20 +4,27 @@ import (
"context"
"net/http"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Module interface {
GetQuickFilters(ctx context.Context, orgID valuer.UUID) ([]*quickfiltertypes.SignalFilters, error)
UpdateQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []v3.AttributeKey) error
GetSignalFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal) (*quickfiltertypes.SignalFilters, error)
// Get returns the stored quick filter row for a source.
Get(ctx context.Context, orgID valuer.UUID, source quickfiltertypes.Source) (*quickfiltertypes.StorableQuickFilter, error)
// GetQuickFilters returns quick filters for a source, or for every source when source is zero.
GetQuickFilters(ctx context.Context, orgID valuer.UUID, source quickfiltertypes.Source) ([]*quickfiltertypes.SourceFilters, error)
UpsertQuickFilters(ctx context.Context, orgID valuer.UUID, source quickfiltertypes.Source, filters []telemetrytypes.TelemetryFieldKey) error
SetDefaultConfig(ctx context.Context, orgID valuer.UUID) error
}
type Handler interface {
// Legacy v1 endpoints, served by converting to and from the v3 attribute key shape.
GetQuickFilters(http.ResponseWriter, *http.Request)
UpdateQuickFilters(http.ResponseWriter, *http.Request)
GetSignalFilters(http.ResponseWriter, *http.Request)
GetSourceFilters(http.ResponseWriter, *http.Request)
ListQuickFiltersV2(http.ResponseWriter, *http.Request)
GetQuickFiltersV2(http.ResponseWriter, *http.Request)
UpdateQuickFiltersV2(http.ResponseWriter, *http.Request)
}

View File

@@ -73,8 +73,8 @@ func (c *captureQuerier) LabelNames(context.Context, *storage.LabelHints, ...*la
}
// metricNamesFromMatchers extracts the statically known metric name, if any.
// The live path derives names from the matched series; the capture path has
// no execution results, so only a __name__ equality contributes.
// Only a __name__ equality contributes; a regex selector needs a series
// lookup to learn the concrete names.
func metricNamesFromMatchers(matchers []*labels.Matcher) []string {
for _, m := range matchers {
if m.Name == metricNameLabel && m.Type == labels.MatchEqual && m.Value != "" {

View File

@@ -88,7 +88,8 @@ func (e *executor) TryExecuteRange(ctx context.Context, qs string, start, end ti
}
// Evaluate every unit concurrently on its own grid (the query grid, or a
// subquery grid); each is one series lookup plus one grid query.
// subquery grid); each is one grid query (see executeUnit for when a
// series lookup precedes it).
results := make([][]transpiledSeries, len(plan.units))
eg, egCtx := errgroup.WithContext(ctx)
for i, unit := range plan.units {
@@ -142,19 +143,27 @@ func (e *executor) executeUnit(ctx context.Context, unit *coreUnit, grid gridCon
dataStart := startMs - unit.offsetMs - windowMs
dataEnd := endMs - unit.offsetMs
seriesQuery, seriesArgs, err := buildSeriesQuery(dataStart, dataEnd, unit.matchers)
if err != nil {
return nil, err
}
lookup, err := e.client.selectSeries(ctx, seriesQuery, seriesArgs)
if err != nil {
return nil, err
}
if len(lookup.fingerprints) == 0 {
return nil, nil
// The group-key join resolves the matchers on its own, so the unit
// statement only needs concrete metric names for the samples
// primary-key prefix. A selector without a static __name__ learns them
// through the series lookup; every other selector skips the roundtrip.
metricNames := metricNamesFromMatchers(unit.matchers)
if metricNames == nil {
seriesQuery, seriesArgs, err := buildSeriesQuery(dataStart, dataEnd, unit.matchers)
if err != nil {
return nil, err
}
lookup, err := e.client.selectSeries(ctx, seriesQuery, seriesArgs)
if err != nil {
return nil, err
}
if len(lookup.fingerprints) == 0 {
return nil, nil
}
metricNames = lookup.metricNames
}
query, args, err := buildUnitSQL(unit, lookup.metricNames, dataStart, dataEnd, startMs, endMs, stepMs, e.client.lookbackMs)
query, args, err := buildUnitSQL(unit, metricNames, dataStart, dataEnd, startMs, endMs, stepMs, e.client.lookbackMs)
if err != nil {
return nil, err
}

View File

@@ -27,9 +27,15 @@ func newTestClient(t *testing.T) (*client, *telemetrystoretest.Provider) {
return newClient(settings, store, prometheus.Config{}), store
}
var seriesCols = []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "labels", Type: "String"},
var unitCols = []cmock.ColumnType{
{Name: "gkey", Type: "String"},
{Name: "grid", Type: "Array(Nullable(Float64))"},
}
// anyArgs matches a bound-argument list by count alone: the mock treats a
// nil expected argument as a wildcard.
func anyArgs(n int) []any {
return make([]any, n)
}
func parse(t *testing.T, q string) parser.Expr {
@@ -553,7 +559,7 @@ func TestTryExecuteRange_WindowedGateFallsBack(t *testing.T) {
// 1m range at 5m step: the windows are disjoint slivers — no
// divisibility or width requirement, so this transpiles.
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("up", int64(1_699_999_200_000), int64(1_700_003_600_000)).WillReturnRows(cmock.NewRows(seriesCols, [][]any{}))
store.Mock().ExpectQuery("FROM signoz_metrics\\.distributed_samples_v4").WithArgs(anyArgs(9)...).WillReturnRows(cmock.NewRows(unitCols, [][]any{}))
_, ok, err = e.TryExecuteRange(context.Background(), `avg_over_time(up[1m])`, start, end, 5*time.Minute)
require.NoError(t, err)
assert.True(t, ok, "range below step is the disjoint form and must transpile")
@@ -637,12 +643,12 @@ func TestTryExecuteRange_LastStyleWindowBelowStepTranspiles(t *testing.T) {
start := time.UnixMilli(1_700_000_000_000)
end := time.UnixMilli(1_700_003_600_000)
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("up", int64(1_699_999_200_000), int64(1_700_003_600_000)).WillReturnRows(cmock.NewRows(seriesCols, [][]any{}))
store.Mock().ExpectQuery("timeSeriesLastToGrid").WithArgs(anyArgs(10)...).WillReturnRows(cmock.NewRows(unitCols, [][]any{}))
_, ok, err := e.TryExecuteRange(context.Background(), `sum by (pod) (up)`, start, end, time.Hour)
require.NoError(t, err)
assert.True(t, ok, "instant selection at step > lookback must transpile")
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("up", int64(1_699_999_200_000), int64(1_700_003_600_000)).WillReturnRows(cmock.NewRows(seriesCols, [][]any{}))
store.Mock().ExpectQuery("timeSeriesLastToGrid").WithArgs(anyArgs(9)...).WillReturnRows(cmock.NewRows(unitCols, [][]any{}))
_, ok, err = e.TryExecuteRange(context.Background(), `last_over_time(up[10m])`, start, end, time.Hour)
require.NoError(t, err)
assert.True(t, ok, "last_over_time at range < step must transpile")

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)
}

211
pkg/prometheus/handler.go Normal file
View File

@@ -0,0 +1,211 @@
package prometheus
import (
"context"
"log/slog"
"math"
"net/http"
"strconv"
"time"
promModel "github.com/prometheus/common/model"
"github.com/prometheus/prometheus/promql"
"github.com/prometheus/prometheus/util/stats"
"github.com/SigNoz/signoz/pkg/errors"
)
// Handler serves the Prometheus HTTP query API over a Prometheus provider:
// /query and /query_range in the shape of Prometheus' /api/v1 endpoints
// (https://prometheus.io/docs/prometheus/latest/querying/api/), intended to
// be mounted under a distinguishing prefix (/prometheus/api/v1) so
// PromQL-only endpoints are separate from the SigNoz query APIs. The request
// and response contracts follow Prometheus: form-encoded GET/POST params,
// {"status":"success","data":{resultType,result}} on success and
// {"status":"error","errorType","error"} with Prometheus' status codes on
// failure — so Prometheus-compatible clients can point at the prefix. The
// wire shapes are documented as OpenAPI schemas in render.go.
type Handler interface {
Query(http.ResponseWriter, *http.Request)
QueryRange(http.ResponseWriter, *http.Request)
}
type handler struct {
logger *slog.Logger
prom Prometheus
}
func NewHandler(logger *slog.Logger, prom Prometheus) Handler {
return &handler{logger: logger, prom: prom}
}
// QueryRange evaluates an expression over a grid: query, start, end, step,
// and optional timeout/stats params, all in Prometheus' formats.
func (h *handler) QueryRange(w http.ResponseWriter, r *http.Request) {
start, err := parseTime(r.FormValue("start"))
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
end, err := parseTime(r.FormValue("end"))
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
if end.Before(start) {
h.respondError(r.Context(), w, errBadData, errors.NewInvalidInputf(errors.CodeInvalidInput, "end timestamp must not be before start time"))
return
}
step, err := parseDuration(r.FormValue("step"))
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
if step <= 0 {
h.respondError(r.Context(), w, errBadData, errors.NewInvalidInputf(errors.CodeInvalidInput, "zero or negative query resolution step widths are not accepted. Try a positive integer"))
return
}
// The engine materializes every point of every series; an unbounded
// grid is an unbounded allocation. 11,000 points covers 60s resolution
// for a week or 1h resolution for a year.
if end.Sub(start)/step > 11000 {
h.respondError(r.Context(), w, errBadData, errors.NewInvalidInputf(errors.CodeInvalidInput, "exceeded maximum resolution of 11,000 points per timeseries. Try decreasing the query resolution (?step=XX)"))
return
}
ctx, cancel, err := h.contextWithTimeout(r)
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
defer cancel()
if h.tryRangeExecutor(ctx, w, r, start, end, step) {
return
}
qry, err := h.prom.Engine().NewRangeQuery(ctx, h.prom.Storage(), nil, r.FormValue("query"), start, end, step)
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
h.exec(ctx, w, r, qry)
}
// tryRangeExecutor serves the query the way a RangeExecutor provider is
// designed to serve: evaluated inside the datastore when the shape allows.
// It reports whether the response was written.
func (h *handler) tryRangeExecutor(ctx context.Context, w http.ResponseWriter, r *http.Request, start, end time.Time, step time.Duration) bool {
re, ok := h.prom.(RangeExecutor)
if !ok {
return false
}
matrix, served, err := re.TryExecuteRange(ctx, r.FormValue("query"), start, end, step)
if err != nil {
h.respondError(ctx, w, errExec, err)
return true
}
if !served {
return false
}
h.respond(ctx, w, &queryData{ResultType: matrix.Type(), Result: matrix}, nil, nil)
return true
}
// Query evaluates an expression at a single instant: query and optional
// time/timeout/stats params. A missing time evaluates at the server's now,
// as in Prometheus.
func (h *handler) Query(w http.ResponseWriter, r *http.Request) {
ts := time.Now()
if t := r.FormValue("time"); t != "" {
var err error
ts, err = parseTime(t)
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
}
ctx, cancel, err := h.contextWithTimeout(r)
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
defer cancel()
qry, err := h.prom.Engine().NewInstantQuery(ctx, h.prom.Storage(), nil, r.FormValue("query"), ts)
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
h.exec(ctx, w, r, qry)
}
func (h *handler) exec(ctx context.Context, w http.ResponseWriter, r *http.Request, qry promql.Query) {
defer qry.Close()
res := qry.Exec(ctx)
if res.Err != nil {
h.logger.ErrorContext(ctx, "error evaluating promql query", errors.Attr(res.Err))
switch res.Err.(type) {
case promql.ErrQueryCanceled:
h.respondError(ctx, w, errCanceled, res.Err)
case promql.ErrQueryTimeout:
h.respondError(ctx, w, errTimeout, res.Err)
case promql.ErrStorage:
h.respondError(ctx, w, errInternal, res.Err)
default:
h.respondError(ctx, w, errExec, res.Err)
}
return
}
data := &queryData{ResultType: res.Value.Type(), Result: res.Value}
if r.FormValue("stats") != "" {
data.Stats = stats.NewQueryStats(qry.Stats())
}
warnings, infos := res.Warnings.AsStrings(r.FormValue("query"), 10, 10)
h.respond(ctx, w, data, warnings, infos)
}
func (h *handler) contextWithTimeout(r *http.Request) (context.Context, context.CancelFunc, error) {
ctx := r.Context()
if to := r.FormValue("timeout"); to != "" {
timeout, err := parseDuration(to)
if err != nil {
return nil, nil, err
}
ctx, cancel := context.WithTimeout(ctx, timeout)
return ctx, cancel, nil
}
ctx, cancel := context.WithCancel(ctx)
return ctx, cancel, nil
}
// parseTime accepts Prometheus' time formats: float unix seconds or RFC3339.
func parseTime(s string) (time.Time, error) {
if t, err := strconv.ParseFloat(s, 64); err == nil {
sec, ns := math.Modf(t)
return time.Unix(int64(sec), int64(ns*float64(time.Second))), nil
}
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t, nil
}
return time.Time{}, errors.NewInvalidInputf(errors.CodeInvalidInput, "cannot parse %q to a valid timestamp", s)
}
// parseDuration accepts Prometheus' duration formats: float seconds or a
// duration string like 5m.
func parseDuration(s string) (time.Duration, error) {
if d, err := strconv.ParseFloat(s, 64); err == nil {
ts := d * float64(time.Second)
if ts > float64(math.MaxInt64) || ts < float64(math.MinInt64) {
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "cannot parse %q to a valid duration. It overflows int64", s)
}
return time.Duration(ts), nil
}
if d, err := promModel.ParseDuration(s); err == nil {
return time.Duration(d), nil
}
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "cannot parse %q to a valid duration", s)
}

164
pkg/prometheus/render.go Normal file
View File

@@ -0,0 +1,164 @@
package prometheus
import (
"context"
"encoding/json"
"net/http"
"github.com/prometheus/prometheus/promql/parser"
"github.com/prometheus/prometheus/util/stats"
"github.com/swaggest/jsonschema-go"
"github.com/SigNoz/signoz/pkg/errors"
)
// This file is the single description of the Prometheus API wire shapes:
// the runtime envelope the handler encodes, and the *Schema types that
// document the same shapes in the generated OpenAPI spec. The contract is
// upstream's (https://prometheus.io/docs/prometheus/latest/querying/api/);
// the schemas describe it, they do not define it.
type errorType string
const (
errBadData errorType = "bad_data"
errExec errorType = "execution"
errCanceled errorType = "canceled"
errTimeout errorType = "timeout"
errInternal errorType = "internal"
)
type queryData struct {
ResultType parser.ValueType `json:"resultType"`
Result parser.Value `json:"result"`
Stats stats.QueryStats `json:"stats,omitempty"`
}
type response struct {
Status string `json:"status"`
Data *queryData `json:"data,omitempty"`
ErrorType errorType `json:"errorType,omitempty"`
Error string `json:"error,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Infos []string `json:"infos,omitempty"`
}
func (h *handler) respond(ctx context.Context, w http.ResponseWriter, data *queryData, warnings, infos []string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(&response{Status: "success", Data: data, Warnings: warnings, Infos: infos}); err != nil {
h.logger.ErrorContext(ctx, "error writing prometheus api response", errors.Attr(err))
}
}
// respondError follows Prometheus' status-code mapping: bad_data 400,
// execution 422, canceled/timeout 503, internal 500.
func (h *handler) respondError(ctx context.Context, w http.ResponseWriter, typ errorType, err error) {
code := http.StatusInternalServerError
switch typ {
case errBadData:
code = http.StatusBadRequest
case errExec:
code = http.StatusUnprocessableEntity
case errCanceled, errTimeout:
code = http.StatusServiceUnavailable
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
if encErr := json.NewEncoder(w).Encode(&response{Status: "error", ErrorType: typ, Error: err.Error()}); encErr != nil {
h.logger.ErrorContext(ctx, "error writing prometheus api error response", errors.Attr(encErr))
}
}
// The endpoints accept parameters as URL query params or a form-encoded
// body, on GET and POST alike.
type QueryParamsSchema struct {
Query string `query:"query" required:"true" description:"PromQL expression."`
Time string `query:"time" description:"Evaluation timestamp: RFC3339 or float unix seconds. Defaults to the server's current time."`
Timeout string `query:"timeout" description:"Evaluation timeout: duration string or float seconds."`
Stats string `query:"stats" description:"Any non-empty value includes query statistics in the response."`
}
type QueryRangeParamsSchema struct {
Query string `query:"query" required:"true" description:"PromQL expression."`
Start string `query:"start" required:"true" description:"Range start: RFC3339 or float unix seconds."`
End string `query:"end" required:"true" description:"Range end: RFC3339 or float unix seconds."`
Step string `query:"step" required:"true" description:"Resolution step: duration string or float seconds."`
Timeout string `query:"timeout" description:"Evaluation timeout: duration string or float seconds."`
Stats string `query:"stats" description:"Any non-empty value includes query statistics in the response."`
}
type SuccessResponseSchema struct {
Status string `json:"status" enum:"success" required:"true"`
Data QueryDataSchema `json:"data" required:"true"`
Warnings []string `json:"warnings,omitempty"`
Infos []string `json:"infos,omitempty"`
}
// QueryDataSchema is the result union, discriminated by resultType.
type QueryDataSchema struct{}
var _ jsonschema.OneOfExposer = QueryDataSchema{}
func (QueryDataSchema) JSONSchemaOneOf() []interface{} {
return []interface{}{MatrixDataSchema{}, VectorDataSchema{}, ScalarDataSchema{}, StringDataSchema{}}
}
type MatrixDataSchema struct {
ResultType string `json:"resultType" enum:"matrix" required:"true"`
Result []MatrixSeriesSchema `json:"result" required:"true"`
}
type MatrixSeriesSchema struct {
Metric map[string]string `json:"metric" required:"true"`
Values []SamplePairSchema `json:"values" required:"true"`
}
type VectorDataSchema struct {
ResultType string `json:"resultType" enum:"vector" required:"true"`
Result []VectorSampleSchema `json:"result" required:"true"`
}
type VectorSampleSchema struct {
Metric map[string]string `json:"metric" required:"true"`
Value SamplePairSchema `json:"value" required:"true"`
}
type ScalarDataSchema struct {
ResultType string `json:"resultType" enum:"scalar" required:"true"`
Result SamplePairSchema `json:"result" required:"true"`
}
type StringDataSchema struct {
ResultType string `json:"resultType" enum:"string" required:"true"`
Result SamplePairSchema `json:"result" required:"true"`
}
// SamplePairSchema is the positional [timestamp, value] pair: a float of
// unix seconds, then the value as a string ("NaN", "+Inf" and "-Inf"
// included). Struct reflection cannot express a positional array, so the
// schema is authored by hand.
type SamplePairSchema struct{}
var _ jsonschema.Exposer = SamplePairSchema{}
func (SamplePairSchema) JSONSchema() (jsonschema.Schema, error) {
item := jsonschema.Schema{}
item.WithOneOf(
(&jsonschema.Schema{}).WithType(jsonschema.Number.Type()).ToSchemaOrBool(),
(&jsonschema.Schema{}).WithType(jsonschema.String.Type()).ToSchemaOrBool(),
)
s := jsonschema.Schema{}
s.WithType(jsonschema.Array.Type())
s.WithMinItems(2)
s.WithMaxItems(2)
s.WithItems(*(&jsonschema.Items{}).WithSchemaOrBool(item.ToSchemaOrBool()))
s.WithDescription(`A [timestamp, value] pair: float unix seconds, then the string-encoded sample value ("NaN", "+Inf", "-Inf" included).`)
return s, nil
}
type ErrorResponseSchema struct {
Status string `json:"status" enum:"error" required:"true"`
ErrorType string `json:"errorType" enum:"bad_data,execution,canceled,timeout,internal" required:"true"`
Error string `json:"error" required:"true"`
}

View File

@@ -1,6 +1,7 @@
package querier
import (
"encoding/json"
"fmt"
"math"
"reflect"
@@ -11,12 +12,12 @@ import (
"strings"
"time"
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/bytedance/sonic"
)
var (
@@ -30,8 +31,6 @@ var (
// written clickhouse query. The column alias indcate which value is
// to be considered as final result (or target).
legacyReservedColumnTargetAliases = []string{"__result", "__value", "result", "res", "value"}
CodeFailUnmarshalJSONColumn = errors.MustNewCode("fail_unmarshal_json_column")
)
// stripKeyAlias removes the __SELECT_KEY_<n>_ / __GROUP_BY_KEY_<n>_ prefix from a result
@@ -40,6 +39,32 @@ func stripKeyAlias(name string) string {
return keyAliasRe.ReplaceAllString(name, "")
}
// unwrapVariant returns the concrete value inside the chcol.Variant envelope the driver scans a
// Dynamic column — a JSON path such as body_v2.level — into.
func unwrapVariant(val any) any {
if v, ok := val.(chcol.Variant); ok {
return v.Any()
}
return val
}
// labelValue renders a group-by value the payload cannot carry as a scalar — a JSON column, or a
// Dynamic one — as a stable string, so that rows differing only in that value land in different
// series. JSON goes through encoding/json for its sorted map keys: ClickHouse groups documents by
// structure, so two rows it considers equal have to produce the same label.
func labelValue(val any) string {
val = unwrapVariant(val)
if val == nil {
return ""
}
if v, ok := val.(telemetrystoretypes.JSONValue); ok {
if raw, err := json.Marshal(v); err == nil {
return string(raw)
}
}
return fmt.Sprint(val)
}
// consume reads every row and shapes it into the payload expected for the
// given request type.
//
@@ -205,6 +230,14 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
Value: *val,
})
case *telemetrystoretypes.JSONValue, *chcol.Variant:
val := labelValue(derefValue(ptr))
lblVals = append(lblVals, val)
lblObjs = append(lblObjs, &qbtypes.Label{
Key: telemetrytypes.TelemetryFieldKey{Name: name},
Value: val,
})
default:
continue
}
@@ -345,7 +378,7 @@ func readAsScalar(rows driver.Rows, queryName string) (*qbtypes.ScalarData, erro
// 2. deref each slot into the output row
row := make([]any, len(scan))
for i, cell := range scan {
row[i] = derefValue(cell)
row[i] = unwrapVariant(derefValue(cell))
}
data = append(data, row)
}
@@ -382,31 +415,13 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
colTypes := rows.ColumnTypes()
colCnt := len(colNames)
// Helper that decides scan target per column based on DB type
makeScanTarget := func(i int) any {
dbt := strings.ToUpper(colTypes[i].DatabaseTypeName())
if strings.HasPrefix(dbt, "JSON") {
// Since the driver fails to decode JSON/Dynamic into native Go values, we read it as raw bytes
// TODO: check in future if fixed in the driver
var v []byte
return &v
}
return reflect.New(colTypes[i].ScanType()).Interface()
}
// Build a template slice of correctly-typed pointers once
scanTpl := make([]any, colCnt)
for i := range colTypes {
scanTpl[i] = makeScanTarget(i)
}
var outRows []*qbtypes.RawRow
for rows.Next() {
// fresh copy of the scan slice (otherwise the driver reuses pointers)
scan := make([]any, colCnt)
for i := range scanTpl {
scan[i] = makeScanTarget(i)
for i := range colTypes {
scan[i] = reflect.New(colTypes[i].ScanType()).Interface()
}
if err := rows.Scan(scan...); err != nil {
@@ -421,21 +436,7 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
name := stripKeyAlias(colNames[i])
// de-reference the typed pointer to any
val := reflect.ValueOf(cellPtr).Elem().Interface()
// Post-process JSON columns: unmarshal bytes into map[string]any
if strings.HasPrefix(strings.ToUpper(colTypes[i].DatabaseTypeName()), "JSON") {
switch x := val.(type) {
case []byte:
var m map[string]any
err := sonic.Unmarshal(x, &m)
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailUnmarshalJSONColumn, "failed to unmarshal JSON column %s", name)
}
val = m
default:
// already a structured type (map[string]any, []any, etc.)
}
}
val := unwrapVariant(reflect.ValueOf(cellPtr).Elem().Interface())
// special-case: timestamp column
if name == "timestamp" || name == "timestamp_datetime" {

View File

@@ -3,8 +3,16 @@ package querier
import (
"reflect"
"testing"
"time"
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/telemetrystore"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMergeSpanAttributeColumns_ParsesEventsAndLinks(t *testing.T) {
@@ -75,6 +83,103 @@ func TestMergeSpanAttributeColumns_ParsesEventsAndLinks(t *testing.T) {
}
}
// A ClickHouse query can put a JSON column in the result of any request type — e.g.
// `select * from signoz_logs.logs_v2` on a body_v2 stack, where `*` covers body_v2.
func TestConsume_JSONColumn(t *testing.T) {
ts := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
body := `{"level":"error","attrs":{"code":500}}`
wantBody := telemetrystoretypes.JSONValue{
"level": "error",
"attrs": map[string]any{"code": float64(500)},
}
// the scalar reader reuses its scan slots across rows, so each row must still carry its own body
t.Run("scalar", func(t *testing.T) {
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
{Name: "body_v2", Type: "JSON"},
{Name: "__result_0", Type: "UInt64"},
}, [][]any{{body, uint64(3)}, {`{"level":"warn"}`, uint64(1)}}))
payload, err := consume(rows, qbtypes.RequestTypeScalar, nil, qbtypes.Step{}, "A")
require.NoError(t, err)
data := payload.(*qbtypes.ScalarData)
require.Len(t, data.Data, 2)
assert.Equal(t, wantBody, data.Data[0][0])
assert.Equal(t, uint64(3), data.Data[0][1])
assert.Equal(t, telemetrystoretypes.JSONValue{"level": "warn"}, data.Data[1][0])
assert.Equal(t, uint64(1), data.Data[1][1])
})
t.Run("time series", func(t *testing.T) {
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
{Name: "ts", Type: "DateTime"},
{Name: "body_v2", Type: "JSON"},
{Name: "__result_0", Type: "UInt64"},
}, [][]any{{ts, body, uint64(3)}}))
payload, err := consume(rows, qbtypes.RequestTypeTimeSeries, nil, qbtypes.Step{}, "A")
require.NoError(t, err)
data := payload.(*qbtypes.TimeSeriesData)
require.Len(t, data.Aggregations, 1)
require.Len(t, data.Aggregations[0].Series, 1)
require.Len(t, data.Aggregations[0].Series[0].Values, 1)
assert.Equal(t, float64(3), data.Aggregations[0].Series[0].Values[0].Value)
})
// grouping by a JSON column is legal in ClickHouse, so each document has to label its own
// series rather than being dropped, which would merge every group into one
t.Run("time series grouped by the JSON column", func(t *testing.T) {
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
{Name: "ts", Type: "DateTime"},
{Name: "body_v2", Type: "JSON"},
{Name: "__result_0", Type: "UInt64"},
}, [][]any{
{ts, `{"level":"error"}`, uint64(7)},
{ts, `{"level":"warn"}`, uint64(2)},
}))
payload, err := consume(rows, qbtypes.RequestTypeTimeSeries, nil, qbtypes.Step{}, "A")
require.NoError(t, err)
data := payload.(*qbtypes.TimeSeriesData)
require.Len(t, data.Aggregations, 1)
require.Len(t, data.Aggregations[0].Series, 2)
got := map[string]float64{}
for _, series := range data.Aggregations[0].Series {
require.Len(t, series.Labels, 1)
require.Len(t, series.Values, 1)
got[series.Labels[0].Value.(string)] = series.Values[0].Value
}
assert.Equal(t, map[string]float64{`{"level":"error"}`: 7, `{"level":"warn"}`: 2}, got)
})
t.Run("raw", func(t *testing.T) {
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
{Name: "timestamp", Type: "DateTime"},
{Name: "body_v2", Type: "JSON"},
}, [][]any{{ts, body}}))
payload, err := consume(rows, qbtypes.RequestTypeRaw, nil, qbtypes.Step{}, "A")
require.NoError(t, err)
data := payload.(*qbtypes.RawData)
require.Len(t, data.Rows, 1)
assert.Equal(t, ts, data.Rows[0].Timestamp.UTC())
assert.Equal(t, wantBody, data.Rows[0].Data["body_v2"])
})
}
// A JSON path (e.g. `body_v2.level`) comes back as a Dynamic column, which the driver scans
// into a chcol.Variant envelope rather than the value itself.
func TestUnwrapVariant(t *testing.T) {
assert.Equal(t, "error", unwrapVariant(chcol.NewDynamicWithType("error", "String")))
assert.Nil(t, unwrapVariant(chcol.Dynamic{}))
assert.Equal(t, uint64(3), unwrapVariant(uint64(3)))
}
func TestMergeSpanAttributeColumns_EmptyEventsAndLinks(t *testing.T) {
data := map[string]any{
"events": []string{},

View File

@@ -13,9 +13,10 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -72,9 +73,13 @@ func (q *querier) postProcessResults(ctx context.Context, orgID valuer.UUID, res
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
if result, ok := typedResults[spec.Name]; ok {
result = postProcessBuilderQuery(q, result, spec, req)
result = q.postProcessLogBody(ctx, orgID, result, req)
result = q.postProcessLogBody(ctx, orgID, result)
typedResults[spec.Name] = result
}
case qbtypes.ClickHouseQuery:
if result, ok := typedResults[spec.Name]; ok {
typedResults[spec.Name] = q.postProcessLogBody(ctx, orgID, result)
}
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
if result, ok := typedResults[spec.Name]; ok {
result = postProcessMetricQuery(q, result, spec, req)
@@ -1051,32 +1056,44 @@ func (q *querier) calculateFormulaStep(expression string, req *qbtypes.QueryRang
return result
}
// postProcessLogBody removes the "message" key from the body map when it is empty.
// Only runs for raw list queries with the use_json_body feature enabled.
func (q *querier) postProcessLogBody(ctx context.Context, orgID valuer.UUID, result *qbtypes.Result, req *qbtypes.QueryRangeRequest) *qbtypes.Result {
if req.RequestType != qbtypes.RequestTypeRaw {
return result
}
// postProcessLogBody removes the empty "message" the typed body path materializes into every
// document, wherever a decoded body lands in the payload — raw rows and scalar cells, under the
// column's own name or the builder's `body` alias. Only runs with the use_json_body feature
// enabled. A time-series label keeps the document verbatim: it is the group key.
func (q *querier) postProcessLogBody(ctx context.Context, orgID valuer.UUID, result *qbtypes.Result) *qbtypes.Result {
if !q.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
return result
}
rawData, ok := result.Value.(*qbtypes.RawData)
if !ok {
return result
}
for _, row := range rawData.Rows {
bodyMap, ok := row.Data["body"].(map[string]any)
if !ok {
continue
switch data := result.Value.(type) {
case *qbtypes.RawData:
for _, row := range data.Rows {
for _, name := range []string{"body", "body_v2"} {
stripEmptyBodyMessage(row.Data[name])
}
}
if msg, exists := bodyMap["message"]; exists {
switch v := msg.(type) {
case string:
if v == "" {
delete(bodyMap, "message")
}
case *qbtypes.ScalarData:
for idx, column := range data.Columns {
if column.Name != "body" && column.Name != "body_v2" {
continue
}
for _, row := range data.Data {
stripEmptyBodyMessage(row[idx])
}
}
}
return result
}
// stripEmptyBodyMessage drops `message: ""` from a decoded body document: the message path is
// typed String in the JSON column, so ClickHouse materializes it even for documents that never
// carried one. Anything that is not a decoded document — the legacy string body, a NULL cell —
// is legal under these names and left alone.
func stripEmptyBodyMessage(val any) {
bodyMap, ok := val.(telemetrystoretypes.JSONValue)
if !ok {
return
}
if msg, ok := bodyMap["message"].(string); ok && msg == "" {
delete(bodyMap, "message")
}
}

View File

@@ -189,6 +189,7 @@ func TestRunExecutesQueriesConcurrently(t *testing.T) {
q := &querier{
logger: instrumentationtest.New().Logger(),
fl: flaggertest.New(t),
maxConcurrentQueries: numQueries,
}
@@ -236,6 +237,7 @@ func TestRunRespectsMaxConcurrentQueries(t *testing.T) {
q := &querier{
logger: instrumentationtest.New().Logger(),
fl: flaggertest.New(t),
maxConcurrentQueries: limit,
}
@@ -273,6 +275,7 @@ func TestRunRespectsMaxConcurrentQueries(t *testing.T) {
func TestRunQueryErrorCancelsSiblings(t *testing.T) {
q := &querier{
logger: instrumentationtest.New().Logger(),
fl: flaggertest.New(t),
maxConcurrentQueries: 4,
}

View File

@@ -387,6 +387,7 @@ func (aH *APIHandler) Respond(w http.ResponseWriter, data interface{}) {
func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/query_range", am.ViewAccess(aH.queryRangeMetrics)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/query", am.ViewAccess(aH.queryMetrics)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules", am.ViewAccess(aH.listRules)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules/{id}", am.ViewAccess(aH.getRule)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules", am.EditAccess(aH.createRule)).Methods(http.MethodPost)
@@ -450,9 +451,9 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/disks", am.ViewAccess(aH.getDisks)).Methods(http.MethodGet)
// Quick Filters
// Quick Filters (v1 routes serve the legacy v3 shape; v2 lives in signozapiserver)
router.HandleFunc("/api/v1/orgs/me/filters", am.ViewAccess(aH.Signoz.Handlers.QuickFilter.GetQuickFilters)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/orgs/me/filters/{signal}", am.ViewAccess(aH.Signoz.Handlers.QuickFilter.GetSignalFilters)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/orgs/me/filters/{signal}", am.ViewAccess(aH.Signoz.Handlers.QuickFilter.GetSourceFilters)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/orgs/me/filters", am.AdminAccess(aH.Signoz.Handlers.QuickFilter.UpdateQuickFilters)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/register", am.OpenAccess(aH.registerUser)).Methods(http.MethodPost)

View File

@@ -544,7 +544,7 @@ func (m *Manager) deleteTask(taskName string) {
}
// CreateRule stores rule def into db and also
// starts an executor for the rule
// starts an executor for the rule, unless the rule is disabled
func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
@@ -611,7 +611,7 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
}
taskName := prepareTaskName(id.StringValue())
if err = m.addTask(ctx, orgID, &parsedRule, taskName); err != nil {
if err = m.syncRuleStateWithTask(ctx, orgID, taskName, &parsedRule); err != nil {
return err
}

View File

@@ -75,6 +75,16 @@ func queryRangeVariables(body []byte) (map[string]qbtypes.VariableItem, error) {
return variables, nil
}
// PromQLResources is the resource set of a bare PromQL query: metrics on
// the promql wildcard, the same ID resourcesForQuery assigns to a PromQL
// query inside a composite — one grant covers both entry points.
func PromQLResources(coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
return []coretypes.ResourceWithID{{
Resource: coretypes.ResourceTelemetryResourceMetrics,
ID: qbtypes.QueryTypePromQL.StringValue() + "/" + coretypes.WildCardSelectorString,
}}, nil
}
func resourcesForQuery(query gjson.Result, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
queryType := query.Get("type").String()
typeWildcard := queryType + "/" + coretypes.WildCardSelectorString

View File

@@ -50,6 +50,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/ruler/signozruler"
@@ -84,6 +85,7 @@ type Handlers struct {
RuleStateHistory rulestatehistory.Handler
SpanMapperHandler spanmapper.Handler
AlertmanagerHandler alertmanager.Handler
PrometheusHandler prometheus.Handler
TraceDetail tracedetail.Handler
RulerHandler ruler.Handler
LLMPricingRuleHandler llmpricingrule.Handler
@@ -104,6 +106,7 @@ func NewHandlers(
zeusService zeus.Zeus,
registryHandler factory.Handler,
alertmanagerService alertmanager.Alertmanager,
prometheusService prometheus.Prometheus,
rulerService ruler.Ruler,
statsAggregator statsreporter.Aggregator,
) Handlers {
@@ -133,6 +136,7 @@ func NewHandlers(
CloudIntegrationHandler: implcloudintegration.NewHandler(modules.CloudIntegration),
SpanMapperHandler: implspanmapper.NewHandler(modules.SpanMapper),
AlertmanagerHandler: signozalertmanager.NewHandler(alertmanagerService),
PrometheusHandler: prometheus.NewHandler(providerSettings.Logger, prometheusService),
TraceDetail: impltracedetail.NewHandler(modules.TraceDetail),
RulerHandler: signozruler.NewHandler(rulerService),
LLMPricingRuleHandler: impllmpricingrule.NewHandler(modules.LLMPricingRule),

View File

@@ -49,7 +49,9 @@ func TestNewHandlers(t *testing.T) {
queryParser := queryparser.New(providerSettings)
require.NoError(t, err)
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser, tagModule)
systemDashboardRegistry, err := impldashboard.NewSystemDashboardRegistry()
require.NoError(t, err)
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser, tagModule, systemDashboardRegistry)
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
require.NoError(t, err)
@@ -63,7 +65,7 @@ func TestNewHandlers(t *testing.T) {
querierHandler := querier.NewHandler(providerSettings, nil, nil)
registryHandler := factory.NewHandler(nil)
handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil, registryHandler, alertmanager, nil, nil)
handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil, registryHandler, alertmanager, nil, nil, nil)
reflectVal := reflect.ValueOf(handlers)
for i := 0; i < reflectVal.NumField(); i++ {
f := reflectVal.Field(i)

View File

@@ -67,35 +67,35 @@ import (
)
type Modules struct {
OrgGetter organization.Getter
OrgSetter organization.Setter
Preference preference.Module
UserSetter user.Setter
UserGetter user.Getter
RetentionGetter retention.Getter
SavedView savedview.Module
Apdex apdex.Module
Dashboard dashboard.Module
QuickFilter quickfilter.Module
TraceFunnel tracefunnel.Module
RawDataExport rawdataexport.Module
AuthDomain authdomain.Module
Session session.Module
Services services.Module
SpanPercentile spanpercentile.Module
MetricsExplorer metricsexplorer.Module
MetricReductionRule metricreductionrule.Module
InfraMonitoring inframonitoring.Module
OrgGetter organization.Getter
OrgSetter organization.Setter
Preference preference.Module
UserSetter user.Setter
UserGetter user.Getter
RetentionGetter retention.Getter
SavedView savedview.Module
Apdex apdex.Module
Dashboard dashboard.Module
QuickFilter quickfilter.Module
TraceFunnel tracefunnel.Module
RawDataExport rawdataexport.Module
AuthDomain authdomain.Module
Session session.Module
Services services.Module
SpanPercentile spanpercentile.Module
MetricsExplorer metricsexplorer.Module
MetricReductionRule metricreductionrule.Module
InfraMonitoring inframonitoring.Module
Promote promote.Module
ServiceAccount serviceaccount.Module
ServiceAccountGetter serviceaccount.Getter
CloudIntegration cloudintegration.Module
LogsPipeline logspipeline.Module
RuleStateHistory rulestatehistory.Module
TraceDetail tracedetail.Module
SpanMapper spanmapper.Module
LLMPricingRule llmpricingrule.Module
Tag tag.Module
LogsPipeline logspipeline.Module
RuleStateHistory rulestatehistory.Module
TraceDetail tracedetail.Module
SpanMapper spanmapper.Module
LLMPricingRule llmpricingrule.Module
Tag tag.Module
}
func NewModules(
@@ -126,7 +126,7 @@ func NewModules(
metricReductionRule metricreductionrule.Module,
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter, dashboard)
// Cleanup callbacks from other modules, invoked when a user is deleted.
onDeleteUser := []user.OnDeleteUser{
dashboard.DeletePreferencesForUser,
@@ -136,34 +136,34 @@ func NewModules(
authDomainModule := implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs, authz)
return Modules{
OrgGetter: orgGetter,
OrgSetter: orgSetter,
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
SavedView: implsavedview.NewModule(implsavedview.NewStore(sqlstore)),
Apdex: implapdex.NewModule(sqlstore),
Dashboard: dashboard,
UserSetter: userSetter,
UserGetter: userGetter,
RetentionGetter: retentionGetter,
QuickFilter: quickfilter,
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier),
AuthDomain: authDomainModule,
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz, config.Global),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
MetricReductionRule: metricReductionRule,
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
OrgGetter: orgGetter,
OrgSetter: orgSetter,
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
SavedView: implsavedview.NewModule(implsavedview.NewStore(sqlstore)),
Apdex: implapdex.NewModule(sqlstore),
Dashboard: dashboard,
UserSetter: userSetter,
UserGetter: userGetter,
RetentionGetter: retentionGetter,
QuickFilter: quickfilter,
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier),
AuthDomain: authDomainModule,
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz, config.Global),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
MetricReductionRule: metricReductionRule,
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
ServiceAccount: serviceAccount,
ServiceAccountGetter: serviceAccountGetter,
LogsPipeline: impllogspipeline.NewModule(sqlstore),
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
Tag: tagModule,
LogsPipeline: impllogspipeline.NewModule(sqlstore),
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
Tag: tagModule,
}
}

View File

@@ -51,7 +51,9 @@ func TestNewModules(t *testing.T) {
queryParser := queryparser.New(providerSettings)
require.NoError(t, err)
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser, tagModule)
systemDashboardRegistry, err := impldashboard.NewSystemDashboardRegistry()
require.NoError(t, err)
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser, tagModule, systemDashboardRegistry)
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
require.NoError(t, err)

View File

@@ -29,6 +29,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/preference"
"github.com/SigNoz/signoz/pkg/modules/promote"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/savedview"
@@ -37,6 +38,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/statsreporter"
@@ -88,11 +90,14 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ rulestatehistory.Handler }{},
struct{ spanmapper.Handler }{},
struct{ alertmanager.Handler }{},
struct{ prometheus.Handler }{},
struct{ llmpricingrule.Handler }{},
struct{ tracedetail.Handler }{},
struct{ ruler.Handler }{},
struct{ statsreporter.Handler }{},
struct{ savedview.Handler }{},
struct{ quickfilter.Module }{},
struct{ quickfilter.Handler }{},
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
if err != nil {
return nil, err

View File

@@ -244,6 +244,10 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewDeleteOrphanUserRolesFactory(),
sqlmigration.NewMigrateLambdaDashboardsFactory(),
sqlmigration.NewAddAuthDomainTuplesFactory(sqlstore),
sqlmigration.NewAddDeploymentHostTuplesFactory(sqlstore),
sqlmigration.NewAddSystemDashboardFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateQuickFiltersFactory(sqlstore),
sqlmigration.NewAddQuickFilterTuplesFactory(sqlstore),
)
}
@@ -342,11 +346,14 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.RuleStateHistory,
handlers.SpanMapperHandler,
handlers.AlertmanagerHandler,
handlers.PrometheusHandler,
handlers.LLMPricingRuleHandler,
handlers.TraceDetail,
handlers.RulerHandler,
handlers.StatsHandler,
handlers.SavedView,
modules.QuickFilter,
handlers.QuickFilter,
),
)
}

View File

@@ -60,6 +60,7 @@ import (
"github.com/SigNoz/signoz/pkg/telemetrystore"
pkgtokenizer "github.com/SigNoz/signoz/pkg/tokenizer"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/version"
@@ -175,7 +176,7 @@ func New(
telemetrystoreProviderFactories factory.NamedMap[factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config]],
authNsCallback func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error),
authzCallback func(context.Context, sqlstore.SQLStore, authz.Config, licensing.Licensing, []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing, tag.Module) dashboard.Module,
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing, tag.Module, dashboardtypes.SystemDashboardRegistry) dashboard.Module,
gatewayProviderFactory func(licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config],
auditorProviderFactories func(licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]],
meterReporterProviderFactories func(context.Context, factory.ProviderSettings, flagger.Flagger, licensing.Licensing, telemetrystore.TelemetryStore, retention.Getter, organization.Getter, zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string),
@@ -440,8 +441,13 @@ func New(
// Initialize query parser (needed for dashboard module)
queryParser := queryparser.New(providerSettings)
// Initialize dashboard module
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing, tagModule)
// Initialize dashboard module. The system dashboard registry is parsed here so
// a malformed embedded definition fails startup instead of a request.
systemDashboardRegistry, err := impldashboard.NewSystemDashboardRegistry()
if err != nil {
return nil, err
}
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing, tagModule, systemDashboardRegistry)
// Initialize user getter
userGetter := impluser.NewGetter(userStore, userRoleStore, flagger)
@@ -610,6 +616,7 @@ func New(
factory.NewNamedService(factory.MustNewName("auditor"), auditor),
factory.NewNamedService(factory.MustNewName("meterreporter"), meterReporter, factory.MustNewName("licensing")),
factory.NewNamedService(factory.MustNewName("ruler"), rulerInstance),
factory.NewNamedService(factory.MustNewName("systemdashboard"), impldashboard.NewService(providerSettings, dashboard, orgGetter)),
)
if err != nil {
return nil, err
@@ -617,7 +624,7 @@ func New(
// Initialize all handlers for the modules
registryHandler := factory.NewHandler(registry)
handlers := NewHandlers(modules, providerSettings, analytics, querierHandler, licensing, global, flagger, gateway, telemetryMetadataStore, authz, zeus, registryHandler, alertmanager, rulerInstance, statsAggregator)
handlers := NewHandlers(modules, providerSettings, analytics, querierHandler, licensing, global, flagger, gateway, telemetryMetadataStore, authz, zeus, registryHandler, alertmanager, prometheus, rulerInstance, statsAggregator)
// Initialize the API server (after registry so it can access service health)
apiserverInstance, err := factory.NewProviderFromNamedMap(

View File

@@ -3,12 +3,13 @@ package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
@@ -39,6 +40,52 @@ func (m *createQuickFilters) Register(migrations *migrate.Migrations) error {
}
func (m *createQuickFilters) Up(ctx context.Context, db *bun.DB) error {
// Frozen copy of the defaults as this migration shipped (hence the old
// camelCase keys); migrations must not read live types. 031 replaces these rows.
defaultFilters := []struct {
signal string
filters []map[string]any
}{
{"traces", []map[string]any{
{"key": "duration_nano", "dataType": "float64", "type": "tag"},
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "hasError", "dataType": "bool", "type": "tag"},
{"key": "serviceName", "dataType": "string", "type": "tag"},
{"key": "name", "dataType": "string", "type": "resource"},
{"key": "rpcMethod", "dataType": "string", "type": "tag"},
{"key": "responseStatusCode", "dataType": "string", "type": "resource"},
{"key": "httpHost", "dataType": "string", "type": "tag"},
{"key": "httpMethod", "dataType": "string", "type": "tag"},
{"key": "httpRoute", "dataType": "string", "type": "tag"},
{"key": "httpUrl", "dataType": "string", "type": "tag"},
{"key": "traceID", "dataType": "string", "type": "tag"},
}},
{"logs", []map[string]any{
{"key": "severity_text", "dataType": "string", "type": "resource"},
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "serviceName", "dataType": "string", "type": "tag"},
{"key": "host.name", "dataType": "string", "type": "resource"},
{"key": "k8s.cluster.name", "dataType": "string", "type": "resource"},
{"key": "k8s.deployment.name", "dataType": "string", "type": "resource"},
{"key": "k8s.namespace.name", "dataType": "string", "type": "resource"},
{"key": "k8s.pod.name", "dataType": "string", "type": "resource"},
}},
{"api_monitoring", []map[string]any{
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "serviceName", "dataType": "string", "type": "tag"},
{"key": "rpcMethod", "dataType": "string", "type": "tag"},
}},
{"exceptions", []map[string]any{
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "serviceName", "dataType": "string", "type": "tag"},
{"key": "host.name", "dataType": "string", "type": "resource"},
{"key": "k8s.cluster.name", "dataType": "string", "type": "tag"},
{"key": "k8s.deployment.name", "dataType": "string", "type": "resource"},
{"key": "k8s.namespace.name", "dataType": "string", "type": "tag"},
{"key": "k8s.pod.name", "dataType": "string", "type": "tag"},
}},
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
@@ -72,15 +119,31 @@ func (m *createQuickFilters) Up(ctx context.Context, db *bun.DB) error {
return err
}
// Get the default quick filters
storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(defaultOrg)
if err != nil {
return err
now := time.Now()
quickFilters := make([]*quickFilter, 0, len(defaultFilters))
for _, defaultFilter := range defaultFilters {
filterJSON, err := json.Marshal(defaultFilter.filters)
if err != nil {
return err
}
quickFilters = append(quickFilters, &quickFilter{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
OrgID: defaultOrg.StringValue(),
Filter: string(filterJSON),
Signal: defaultFilter.signal,
TimeAuditable: types.TimeAuditable{
CreatedAt: now,
UpdatedAt: now,
},
})
}
// Insert all filters at once
_, err = tx.NewInsert().
Model(&storableQuickFilters).
Model(&quickFilters).
Exec(ctx)
if err != nil {

View File

@@ -3,11 +3,13 @@ package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
@@ -38,6 +40,61 @@ func (migration *updateQuickFilters) Register(migrations *migrate.Migrations) er
}
func (migration *updateQuickFilters) Up(ctx context.Context, db *bun.DB) error {
// Frozen copy of the defaults as this migration shipped; migrations must not
// read live types. api_monitoring's service.name is "tag" here — 035 fixes it.
defaultFilters := []struct {
signal string
filters []map[string]any
}{
{"traces", []map[string]any{
{"key": "duration_nano", "dataType": "float64", "type": "tag"},
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "hasError", "dataType": "bool", "type": "tag"},
{"key": "service.name", "dataType": "string", "type": "resource"},
{"key": "name", "dataType": "string", "type": "tag"},
{"key": "rpc.method", "dataType": "string", "type": "tag"},
{"key": "response_status_code", "dataType": "string", "type": "tag"},
{"key": "http_host", "dataType": "string", "type": "tag"},
{"key": "http.method", "dataType": "string", "type": "tag"},
{"key": "http.route", "dataType": "string", "type": "tag"},
{"key": "http_url", "dataType": "string", "type": "tag"},
{"key": "trace_id", "dataType": "string", "type": "tag"},
}},
{"logs", []map[string]any{
{"key": "severity_text", "dataType": "string", "type": "resource"},
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "service.name", "dataType": "string", "type": "resource"},
{"key": "host.name", "dataType": "string", "type": "resource"},
{"key": "k8s.cluster.name", "dataType": "string", "type": "resource"},
{"key": "k8s.deployment.name", "dataType": "string", "type": "resource"},
{"key": "k8s.namespace.name", "dataType": "string", "type": "resource"},
{"key": "k8s.pod.name", "dataType": "string", "type": "resource"},
}},
{"api_monitoring", []map[string]any{
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "service.name", "dataType": "string", "type": "tag"},
{"key": "rpc.method", "dataType": "string", "type": "tag"},
}},
{"exceptions", []map[string]any{
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "service.name", "dataType": "string", "type": "resource"},
{"key": "host.name", "dataType": "string", "type": "resource"},
{"key": "k8s.cluster.name", "dataType": "string", "type": "resource"},
{"key": "k8s.deployment.name", "dataType": "string", "type": "resource"},
{"key": "k8s.namespace.name", "dataType": "string", "type": "resource"},
{"key": "k8s.pod.name", "dataType": "string", "type": "resource"},
}},
}
signalFilters := make([]struct{ signal, filter string }, 0, len(defaultFilters))
for _, defaultFilter := range defaultFilters {
filterJSON, err := json.Marshal(defaultFilter.filters)
if err != nil {
return err
}
signalFilters = append(signalFilters, struct{ signal, filter string }{defaultFilter.signal, string(filterJSON)})
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
@@ -73,17 +130,28 @@ func (migration *updateQuickFilters) Up(ctx context.Context, db *bun.DB) error {
return err
}
// For each organization, create new quick filters with the updated NewDefaultQuickFilter function
// For each organization, create new quick filters with the updated defaults
for _, orgID := range orgIDs {
// Get the updated default quick filters
storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(valuer.MustNewUUID(orgID))
if err != nil {
return err
now := time.Now()
quickFilters := make([]*quickFilter, 0, len(signalFilters))
for _, signalFilter := range signalFilters {
quickFilters = append(quickFilters, &quickFilter{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
OrgID: orgID,
Filter: signalFilter.filter,
Signal: signalFilter.signal,
TimeAuditable: types.TimeAuditable{
CreatedAt: now,
UpdatedAt: now,
},
})
}
// Insert all filters for this organization
_, err = tx.NewInsert().
Model(&storableQuickFilters).
Model(&quickFilters).
Exec(ctx)
if err != nil {

View File

@@ -2,20 +2,16 @@ 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/quickfiltertypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type updateApiMonitoringFilters struct {
store sqlstore.SQLStore
}
type updateApiMonitoringFilters struct{}
func NewUpdateApiMonitoringFiltersFactory(store sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("update_api_monitoring_filters"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
@@ -23,10 +19,8 @@ func NewUpdateApiMonitoringFiltersFactory(store sqlstore.SQLStore) factory.Provi
})
}
func newUpdateApiMonitoringFilters(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) {
return &updateApiMonitoringFilters{
store: store,
}, nil
func newUpdateApiMonitoringFilters(_ context.Context, _ factory.ProviderSettings, _ Config, _ sqlstore.SQLStore) (SQLMigration, error) {
return &updateApiMonitoringFilters{}, nil
}
func (migration *updateApiMonitoringFilters) Register(migrations *migrate.Migrations) error {
@@ -38,63 +32,29 @@ func (migration *updateApiMonitoringFilters) Register(migrations *migrate.Migrat
}
func (migration *updateApiMonitoringFilters) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
// Frozen copy of the api_monitoring defaults as this migration shipped; the
// change over 031 is service.name moving from "tag" to "resource".
apiMonitoringFilters := []map[string]any{
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "service.name", "dataType": "string", "type": "resource"},
{"key": "rpc.method", "dataType": "string", "type": "tag"},
}
apiMonitoringFilterJSON, err := json.Marshal(apiMonitoringFilters)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
// Get all organization IDs as strings
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
// The filter JSON is org-independent, so one update covers every org's row.
_, err = db.NewUpdate().
Table("quick_filter").
Set("filter = ?, updated_at = ?", string(apiMonitoringFilterJSON), time.Now()).
Where("signal = ?", "api_monitoring").
Exec(ctx)
if err != nil {
if err == sql.ErrNoRows {
if err := tx.Commit(); err != nil {
return err
}
return nil
}
return err
}
for _, orgID := range orgIDs {
// Get the updated default quick filters which includes the new API monitoring filters
storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(valuer.MustNewUUID(orgID))
if err != nil {
return err
}
// Find the API monitoring filter from the storable quick filters
var apiMonitoringFilterJSON string
for _, filter := range storableQuickFilters {
if filter.Signal == quickfiltertypes.SignalApiMonitoring {
apiMonitoringFilterJSON = filter.Filter
break
}
}
if apiMonitoringFilterJSON != "" {
_, err = tx.NewUpdate().
Table("quick_filter").
Set("filter = ?, updated_at = ?", apiMonitoringFilterJSON, time.Now()).
Where("signal = ? AND org_id = ?", quickfiltertypes.SignalApiMonitoring, orgID).
Exec(ctx)
if err != nil {
return err
}
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}

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,93 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addSystemDashboard struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddSystemDashboardFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("add_system_dashboard"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addSystemDashboard{sqlstore: sqlstore, sqlschema: sqlschema}, nil
},
)
}
func (migration *addSystemDashboard) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addSystemDashboard) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
sqls := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "system_dashboard",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "dashboard_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "version", DataType: sqlschema.DataTypeBigInt, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
ColumnNames: []sqlschema.ColumnName{"id"},
},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("org_id"),
ReferencedTableName: sqlschema.TableName("organizations"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
{
ReferencingColumnName: sqlschema.ColumnName("dashboard_id"),
ReferencedTableName: sqlschema.TableName("dashboard"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
// (org_id, name) is what makes provisioning safe across replicas: the state
// row is written in the same transaction as the dashboard, so a losing racer
// rolls back its dashboard too.
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(
&sqlschema.UniqueIndex{
TableName: "system_dashboard",
ColumnNames: []sqlschema.ColumnName{"org_id", "name"},
},
)...)
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(
&sqlschema.UniqueIndex{
TableName: "system_dashboard",
ColumnNames: []sqlschema.ColumnName{"dashboard_id"},
},
)...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addSystemDashboard) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,180 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"strings"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
)
type storableQuickFilterRow struct {
bun.BaseModel `bun:"table:quick_filter"`
ID string `bun:"id,pk"`
Filter string `bun:"filter"`
}
// legacyQuickFilterEntry carries both shapes a stored entry can be in: the
// legacy key/type/dataType shape and the current name-carrying shape.
type legacyQuickFilterEntry struct {
Name string `json:"name"`
Key string `json:"key"`
Type string `json:"type"`
DataType string `json:"dataType"`
Signal string `json:"signal"`
}
// quickFilterLegacyTypeToFieldContext maps the v3 attribute key types the v1
// write path could store. Materialized top-level fields carried no type, and
// anything unknown (e.g. "Sum" in the old meter defaults) normalizes to
// unspecified, matching what the v1 write path does at runtime.
var quickFilterLegacyTypeToFieldContext = map[string]string{
"tag": "attribute",
"resource": "resource",
"scope": "scope",
}
// quickFilterLegacyDataTypeToFieldDataType maps the v3 attribute key data
// types the v1 write path could store, with numerics collapsed to number,
// matching the fields API and the v1 write path.
var quickFilterLegacyDataTypeToFieldDataType = map[string]string{
"string": "string",
"bool": "bool",
"int64": "number",
"float64": "number",
}
type migrateQuickFilters struct {
sqlstore sqlstore.SQLStore
settings factory.ProviderSettings
}
func NewMigrateQuickFiltersFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("migrate_quick_filters"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &migrateQuickFilters{sqlstore: sqlstore, settings: ps}, nil
})
}
func (migration *migrateQuickFilters) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *migrateQuickFilters) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var rows []*storableQuickFilterRow
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
var migrated, skipped int
for _, row := range rows {
migratedFilter, changed, ok := migrateQuickFilterEntries(row.Filter)
if !ok {
migration.settings.Logger.WarnContext(ctx, "quick filter could not be parsed, leaving it untouched", slog.String("quick_filter_id", row.ID), slog.String("raw_filter", row.Filter))
skipped++
continue
}
if !changed {
continue
}
migrated++
if _, err := tx.NewUpdate().Model((*storableQuickFilterRow)(nil)).Set("filter = ?", migratedFilter).Where("id = ?", row.ID).Exec(ctx); err != nil {
return err
}
}
migration.settings.Logger.InfoContext(ctx, "migrated quick filters to telemetry field keys", slog.Int("total", len(rows)), slog.Int("migrated", migrated), slog.Int("skipped", skipped))
if _, err := migration.sqlstore.Dialect().RenameColumn(ctx, tx, "quick_filter", "signal", "source"); err != nil {
return err
}
for _, column := range []string{"created_by", "updated_by"} {
if err := migration.sqlstore.Dialect().DropColumn(ctx, tx, "quick_filter", column); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *migrateQuickFilters) Down(context.Context, *bun.DB) error {
return nil
}
// migrateQuickFilterEntries rewrites a stored filter list from the legacy
// key/dataType/type shape to telemetry field keys; ok=false means unparseable.
func migrateQuickFilterEntries(filter string) (migrated string, changed bool, ok bool) {
var entriesRaw []json.RawMessage
if err := json.Unmarshal([]byte(filter), &entriesRaw); err != nil {
return "", false, false
}
migratedEntries := make([]json.RawMessage, 0, len(entriesRaw))
for _, rawEntry := range entriesRaw {
var entry legacyQuickFilterEntry
if err := json.Unmarshal(rawEntry, &entry); err != nil {
// Some stored entries are plain strings rather than objects; treat
// the string as the filter key name, dropping empty ones.
var name string
if err := json.Unmarshal(rawEntry, &name); err != nil {
return "", false, false
}
entry = legacyQuickFilterEntry{Key: name}
}
switch {
case entry.Name != "":
migratedEntries = append(migratedEntries, rawEntry)
case entry.Key != "":
migratedJSON, err := marshalUnescaped(telemetryFieldKeyOutput{
Name: entry.Key,
Signal: entry.Signal,
FieldContext: quickFilterFieldContext(entry.Type),
FieldDataType: quickFilterFieldDataType(entry.DataType),
})
if err != nil {
return "", false, false
}
migratedEntries = append(migratedEntries, migratedJSON)
changed = true
default:
changed = true
}
}
if !changed {
return "", false, true
}
migratedJSON, err := marshalUnescaped(migratedEntries)
if err != nil {
return "", false, false
}
return string(migratedJSON), true, true
}
// quickFilterFieldDataType resolves legacy datatype spellings, with unknowns
// normalized to unspecified.
func quickFilterFieldDataType(legacyDataType string) string {
return quickFilterLegacyDataTypeToFieldDataType[strings.ToLower(strings.TrimSpace(legacyDataType))]
}
// quickFilterFieldContext resolves legacy type spellings, with unknowns
// normalized to unspecified.
func quickFilterFieldContext(legacyType string) string {
return quickFilterLegacyTypeToFieldContext[strings.ToLower(strings.TrimSpace(legacyType))]
}

View File

@@ -0,0 +1,139 @@
package sqlmigration
import (
"context"
"database/sql"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addQuickFilterTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddQuickFilterTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_quick_filter_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addQuickFilterTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addQuickFilterTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addQuickFilterTuples) 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
// quick-filter moved from the legacy ViewAccess/AdminAccess role gate 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", "quick-filter", "read"},
{authtypes.SigNozAdminRoleName, "metaresource", "quick-filter", "update"},
{authtypes.SigNozAdminRoleName, "metaresource", "quick-filter", "list"},
{authtypes.SigNozEditorRoleName, "metaresource", "quick-filter", "read"},
{authtypes.SigNozEditorRoleName, "metaresource", "quick-filter", "list"},
{authtypes.SigNozViewerRoleName, "metaresource", "quick-filter", "read"},
{authtypes.SigNozViewerRoleName, "metaresource", "quick-filter", "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
}
}
}
}
return tx.Commit()
}
func (migration *addQuickFilterTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -184,7 +184,7 @@ func (p *provider) Query(ctx context.Context, query string, args ...interface{})
}
return &rowsWithHooks{
Rows: rows,
Rows: telemetrystore.WrapRows(rows),
ctx: ctx,
event: event,
onClose: func() { telemetrystore.WrapAfterQuery(p.hooks, ctx, event) },

View File

@@ -0,0 +1,39 @@
package telemetrystore
import (
"reflect"
"strings"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
)
// WrapRows reports JSONValue as the scan type of every JSON column. Nested JSON — Array(JSON),
// Map(String, JSON) — is not covered.
func WrapRows(rows driver.Rows) driver.Rows {
return &rowsWithJSONScanType{Rows: rows}
}
type rowsWithJSONScanType struct {
driver.Rows
}
func (r *rowsWithJSONScanType) ColumnTypes() []driver.ColumnType {
colTypes := r.Rows.ColumnTypes()
wrapped := make([]driver.ColumnType, len(colTypes))
for i, colType := range colTypes {
wrapped[i] = colType
if strings.HasPrefix(strings.ToUpper(colType.DatabaseTypeName()), "JSON") {
wrapped[i] = jsonColumnType{ColumnType: colType}
}
}
return wrapped
}
type jsonColumnType struct {
driver.ColumnType
}
func (jsonColumnType) ScanType() reflect.Type {
return reflect.TypeFor[telemetrystoretypes.JSONValue]()
}

View File

@@ -0,0 +1,23 @@
package telemetrystoretest
import (
"context"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/SigNoz/signoz/pkg/telemetrystore"
)
// conn wraps rows the way the clickhouse provider does, so mocked JSON columns report the scan
// type they do in production.
type conn struct {
clickhouse.Conn
}
func (c conn) Query(ctx context.Context, query string, args ...any) (driver.Rows, error) {
rows, err := c.Conn.Query(ctx, query, args...)
if err != nil {
return nil, err
}
return telemetrystore.WrapRows(rows), nil
}

View File

@@ -32,7 +32,7 @@ func New(_ telemetrystore.Config, matcher sqlmock.QueryMatcher) *Provider {
// ClickhouseDB returns the mock Clickhouse connection.
func (p *Provider) ClickhouseDB() clickhouse.Conn {
return p.clickhouseDB.(clickhouse.Conn)
return conn{Conn: p.clickhouseDB.(clickhouse.Conn)}
}
// Cluster returns the cluster name.

View File

@@ -0,0 +1,173 @@
// Package adf converts Markdown into Atlassian Document Format (ADF) nodes,
// the JSON rich-text format used by Jira Cloud's v3 API.
package adf
import (
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
extast "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/text"
)
// parser is stateless across Parse calls and safe for concurrent use; only
// goldmark's renderers hold per-document state (which we don't use). Strikethrough
// is included for the strike mark; linkify is deliberately omitted since it
// fragments plain text into word tokens while scanning for bare URLs.
var parser = goldmark.New(goldmark.WithExtensions(extension.Strikethrough)).Parser()
// Render returns the ADF block nodes for markdown (without the doc wrapper),
// so callers can embed them alongside their own nodes (panels, links, …).
func Render(markdown string) []any {
src := []byte(markdown)
return blockChildren(parser.Parse(text.NewReader(src)), src)
}
func blockChildren(parent ast.Node, src []byte) []any {
var out []any
for c := parent.FirstChild(); c != nil; c = c.NextSibling() {
if b := block(c, src); b != nil {
out = append(out, b)
}
}
return out
}
func block(n ast.Node, src []byte) any {
switch node := n.(type) {
case *ast.Heading:
return map[string]any{"type": "heading", "attrs": map[string]any{"level": node.Level}, "content": inlineChildren(node, src, nil)}
case *ast.Paragraph:
return paragraph(inlineChildren(node, src, nil))
case *ast.TextBlock:
return paragraph(inlineChildren(node, src, nil))
case *ast.List:
typ := "bulletList"
if node.IsOrdered() {
typ = "orderedList"
}
return map[string]any{"type": typ, "content": blockChildren(node, src)}
case *ast.ListItem:
return map[string]any{"type": "listItem", "content": blockChildren(node, src)}
case *ast.Blockquote:
return map[string]any{"type": "blockquote", "content": blockChildren(node, src)}
case *ast.FencedCodeBlock:
return codeBlock(codeText(node, src), string(node.Language(src)))
case *ast.CodeBlock:
return codeBlock(codeText(node, src), "")
case *ast.ThematicBreak:
return map[string]any{"type": "rule"}
default:
return nil
}
}
func paragraph(content []any) map[string]any {
p := map[string]any{"type": "paragraph"}
if len(content) > 0 {
p["content"] = content
}
return p
}
func codeBlock(code, lang string) map[string]any {
cb := map[string]any{"type": "codeBlock"}
if lang != "" {
cb["attrs"] = map[string]any{"language": lang}
}
if code = strings.TrimRight(code, "\n"); code != "" {
cb["content"] = []any{map[string]any{"type": "text", "text": code}}
}
return cb
}
// inlineChildren flattens an inline subtree into ADF text nodes, carrying the
// active marks (strong/em/code/strike/link) down the tree.
func inlineChildren(parent ast.Node, src []byte, marks []any) []any {
var out []any
for c := parent.FirstChild(); c != nil; c = c.NextSibling() {
switch node := c.(type) {
case *ast.Text:
if t := string(node.Segment.Value(src)); t != "" {
out = append(out, textNode(t, marks))
}
if node.HardLineBreak() {
out = append(out, map[string]any{"type": "hardBreak"})
} else if node.SoftLineBreak() {
out = append(out, textNode(" ", marks))
}
case *ast.String:
if len(node.Value) > 0 {
out = append(out, textNode(string(node.Value), marks))
}
case *ast.CodeSpan:
if t := rawText(node, src); t != "" {
out = append(out, textNode(t, withMark(marks, mark("code"))))
}
case *ast.Emphasis:
m := "em"
if node.Level == 2 {
m = "strong"
}
out = append(out, inlineChildren(node, src, withMark(marks, mark(m)))...)
case *extast.Strikethrough:
out = append(out, inlineChildren(node, src, withMark(marks, mark("strike")))...)
case *ast.Link:
out = append(out, inlineChildren(node, src, withMark(marks, linkMark(string(node.Destination))))...)
case *ast.AutoLink:
if u := string(node.URL(src)); u != "" {
out = append(out, textNode(u, withMark(marks, linkMark(u))))
}
default:
out = append(out, inlineChildren(c, src, marks)...)
}
}
return out
}
func textNode(s string, marks []any) map[string]any {
tn := map[string]any{"type": "text", "text": s}
if len(marks) > 0 {
tn["marks"] = marks
}
return tn
}
func mark(typ string) any { return map[string]any{"type": typ} }
func linkMark(href string) any {
return map[string]any{"type": "link", "attrs": map[string]any{"href": href}}
}
func withMark(marks []any, m any) []any {
out := make([]any, 0, len(marks)+1)
out = append(out, marks...)
return append(out, m)
}
func rawText(n ast.Node, src []byte) string {
var b strings.Builder
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
switch t := c.(type) {
case *ast.Text:
b.Write(t.Segment.Value(src))
case *ast.String:
b.Write(t.Value)
default:
b.WriteString(rawText(c, src))
}
}
return b.String()
}
func codeText(n ast.Node, src []byte) string {
var b strings.Builder
lines := n.Lines()
for i := 0; i < lines.Len(); i++ {
seg := lines.At(i)
b.Write(seg.Value(src))
}
return b.String()
}

View File

@@ -0,0 +1,85 @@
package adf
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func toJSON(t *testing.T, v any) string {
t.Helper()
b, err := json.Marshal(v)
require.NoError(t, err)
return string(b)
}
func TestRenderInlineMarks(t *testing.T) {
js := toJSON(t, Render("**bold** and *em* and `code` and [txt](https://x.io)"))
assert.Contains(t, js, `"type":"strong"`)
assert.Contains(t, js, `"type":"em"`)
assert.Contains(t, js, `"type":"code"`)
assert.Contains(t, js, `"type":"link"`)
assert.Contains(t, js, `"href":"https://x.io"`)
assert.Contains(t, js, `"text":"bold"`)
}
func TestRenderHeadingAndList(t *testing.T) {
js := toJSON(t, Render("# Title\n\n- a\n- b"))
assert.Contains(t, js, `"type":"heading"`)
assert.Contains(t, js, `"level":1`)
assert.Contains(t, js, `"type":"bulletList"`)
assert.Contains(t, js, `"type":"listItem"`)
}
func TestRenderOrderedList(t *testing.T) {
js := toJSON(t, Render("1. one\n2. two"))
assert.Contains(t, js, `"type":"orderedList"`)
}
func TestRenderCodeBlock(t *testing.T) {
js := toJSON(t, Render("```go\nx := 1\n```"))
assert.Contains(t, js, `"type":"codeBlock"`)
assert.Contains(t, js, `"language":"go"`)
assert.Contains(t, js, `x := 1`)
}
func TestRenderStrikethrough(t *testing.T) {
js := toJSON(t, Render("~~gone~~"))
assert.Contains(t, js, `"type":"strike"`)
assert.Contains(t, js, `"text":"gone"`)
}
func TestRenderBlockquote(t *testing.T) {
js := toJSON(t, Render("> quoted"))
assert.Contains(t, js, `"type":"blockquote"`)
assert.Contains(t, js, `"text":"quoted"`)
}
func TestRenderAutoLink(t *testing.T) {
js := toJSON(t, Render("see <https://signoz.io>"))
assert.Contains(t, js, `"type":"link"`)
assert.Contains(t, js, `"href":"https://signoz.io"`)
assert.Contains(t, js, `"text":"https://signoz.io"`)
}
func TestRenderLineBreaks(t *testing.T) {
js := toJSON(t, Render("one \ntwo"))
assert.Contains(t, js, `"type":"hardBreak"`)
// a soft break renders as a space, keeping the paragraph intact
js = toJSON(t, Render("one\ntwo"))
assert.NotContains(t, js, `"type":"hardBreak"`)
assert.Contains(t, js, `"text":" "`)
}
func TestRenderPlainText(t *testing.T) {
js := toJSON(t, Render("just text"))
assert.Contains(t, js, `"type":"paragraph"`)
assert.Contains(t, js, `"text":"just text"`)
}
func TestRenderEmptyIsEmpty(t *testing.T) {
assert.Empty(t, Render(""))
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/blockkit"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/mrkdwn"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/plaintext"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
)
@@ -32,6 +33,11 @@ var (
return goldmark.New(goldmark.WithExtensions(mrkdwn.Extender))
},
}
plaintextPool = sync.Pool{
New: func() any {
return goldmark.New(goldmark.WithExtensions(plaintext.Extender))
},
}
)
// RenderHTML converts markdown to HTML.
@@ -53,6 +59,14 @@ func RenderSlackMrkdwn(markdown string) (string, error) {
return render(md, markdown, "Slack mrkdwn")
}
// RenderPlainText converts markdown to plain text: no markers, links flattened
// to "text (url)".
func RenderPlainText(markdown string) (string, error) {
md := plaintextPool.Get().(goldmark.Markdown)
defer plaintextPool.Put(md)
return render(md, markdown, "plain text")
}
func render(md goldmark.Markdown, markdown string, format string) (string, error) {
var buf bytes.Buffer
if err := md.Convert([]byte(markdown), &buf); err != nil {

View File

@@ -0,0 +1,301 @@
// Package plaintext provides a goldmark node renderer that emits plain text:
// no markdown or HTML markers, and links flattened to "text (url)". It is used
// for JSM Ops timeline notes, which render neither HTML nor markdown.
package plaintext
import (
"bytes"
"fmt"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
extensionast "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/util"
)
// Extender registers the plain-text node renderer plus the GFM extensions it
// handles (tables, strikethrough).
var Extender goldmark.Extender = &extender{}
type extender struct{}
func (e *extender) Extend(m goldmark.Markdown) {
extension.Table.Extend(m)
extension.Strikethrough.Extend(m)
m.Renderer().AddOptions(
renderer.WithNodeRenderers(util.Prioritized(newRenderer(), 1)),
)
}
// nodeRenderer holds per-document nesting prefixes, so it is not safe for
// concurrent Convert calls; callers pool one instance per goroutine.
type nodeRenderer struct {
prefixes []string
}
func newRenderer() renderer.NodeRenderer {
return &nodeRenderer{}
}
func (r *nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
// Blocks
reg.Register(ast.KindDocument, r.renderDocument)
reg.Register(ast.KindHeading, r.renderBlock)
reg.Register(ast.KindBlockquote, r.renderBlock)
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
reg.Register(ast.KindFencedCodeBlock, r.renderCodeBlock)
reg.Register(ast.KindHTMLBlock, r.renderHTMLBlock)
reg.Register(ast.KindList, r.renderList)
reg.Register(ast.KindListItem, r.renderListItem)
reg.Register(ast.KindParagraph, r.renderBlock)
reg.Register(ast.KindTextBlock, r.renderTextBlock)
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
// Inlines
reg.Register(ast.KindAutoLink, r.renderAutoLink)
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
reg.Register(ast.KindEmphasis, r.renderPassthrough)
reg.Register(ast.KindImage, r.renderLink)
reg.Register(ast.KindLink, r.renderLink)
reg.Register(ast.KindText, r.renderText)
reg.Register(ast.KindString, r.renderString)
reg.Register(ast.KindRawHTML, r.renderRawHTML)
// Extensions
reg.Register(extensionast.KindStrikethrough, r.renderPassthrough)
reg.Register(extensionast.KindTable, r.renderTable)
}
func (r *nodeRenderer) writePrefix(w util.BufWriter) {
for _, p := range r.prefixes {
_, _ = w.WriteString(p)
}
}
func (r *nodeRenderer) writeLineSeparator(w util.BufWriter) {
_ = w.WriteByte('\n')
r.writePrefix(w)
}
// writeBlockSeparator writes a blank line between block-level elements.
func (r *nodeRenderer) writeBlockSeparator(w util.BufWriter) {
r.writeLineSeparator(w)
r.writeLineSeparator(w)
}
func (r *nodeRenderer) separateFromPrevious(w util.BufWriter, n ast.Node) {
if n.PreviousSibling() != nil {
r.writeBlockSeparator(w)
}
}
func (r *nodeRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
// The renderer is pooled; wipe any prefix stack left over from a prior
// document (e.g. one that errored mid-walk) before starting fresh.
r.prefixes = r.prefixes[:0]
}
return ast.WalkContinue, nil
}
// renderBlock separates block-level nodes (paragraph, heading, blockquote) from
// their previous sibling with a blank line, emitting no markers of their own.
func (r *nodeRenderer) renderBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.separateFromPrevious(w, node)
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.separateFromPrevious(w, n)
l := n.Lines().Len()
for i := 0; i < l; i++ {
line := n.Lines().At(i)
_, _ = w.Write(line.Value(source))
}
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering && node.PreviousSibling() != nil {
r.writeLineSeparator(w)
if node.Parent() == nil || node.Parent().Kind() != ast.KindListItem {
r.writeLineSeparator(w)
}
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderListItem(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if n.PreviousSibling() != nil {
r.writeLineSeparator(w)
}
parent := n.Parent().(*ast.List)
var prefixStr string
if parent.IsOrdered() {
index := parent.Start
for c := parent.FirstChild(); c != nil && c != n; c = c.NextSibling() {
index++
}
prefixStr = fmt.Sprintf("%d. ", index)
} else {
prefixStr = "- "
}
_, _ = w.WriteString(prefixStr)
r.prefixes = append(r.prefixes, " ") // indent wrapped/nested lines
} else {
r.prefixes = r.prefixes[:len(r.prefixes)-1]
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderTextBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering && n.PreviousSibling() != nil {
r.writeLineSeparator(w)
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderThematicBreak(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.separateFromPrevious(w, n)
_, _ = w.WriteString("---")
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.AutoLink)
url := string(n.URL(source))
if n.AutoLinkType == ast.AutoLinkEmail && !strings.HasPrefix(strings.ToLower(url), "mailto:") {
url = "mailto:" + url
}
_, _ = w.WriteString(url)
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
segment := c.(*ast.Text).Segment
value := segment.Value(source)
if bytes.HasSuffix(value, []byte("\n")) {
_, _ = w.Write(value[:len(value)-1])
_ = w.WriteByte(' ')
} else {
_, _ = w.Write(value)
}
}
return ast.WalkSkipChildren, nil
}
return ast.WalkContinue, nil
}
// renderPassthrough emits no markers; the node's children render as plain text
// (used for emphasis/strong and strikethrough).
func (r *nodeRenderer) renderPassthrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
return ast.WalkContinue, nil
}
// renderLink flattens links and images to "text (url)": children render the
// label, then the destination is appended in parentheses on exit.
func (r *nodeRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
var dest []byte
switch n := node.(type) {
case *ast.Link:
dest = n.Destination
case *ast.Image:
dest = n.Destination
}
if !entering && len(dest) > 0 {
_, _ = fmt.Fprintf(w, " (%s)", dest)
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.Text)
_, _ = w.Write(n.Segment.Value(source))
if n.HardLineBreak() || n.SoftLineBreak() {
r.writeLineSeparator(w)
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
_, _ = w.Write(node.(*ast.String).Value)
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
// Drop inline raw HTML tags; a plain-text note should never carry markup.
return ast.WalkSkipChildren, nil
}
func (r *nodeRenderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
// Drop block-level raw HTML for the same reason as inline raw HTML.
return ast.WalkSkipChildren, nil
}
func (r *nodeRenderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
r.separateFromPrevious(w, node)
first := true
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
if c.Kind() != extensionast.KindTableHeader && c.Kind() != extensionast.KindTableRow {
continue
}
if !first {
r.writeLineSeparator(w)
}
first = false
cellFirst := true
for cc := c.FirstChild(); cc != nil; cc = cc.NextSibling() {
if cc.Kind() != extensionast.KindTableCell {
continue
}
if !cellFirst {
_, _ = w.WriteString(" | ")
}
cellFirst = false
_, _ = w.WriteString(extractPlainText(cc, source))
}
}
return ast.WalkSkipChildren, nil
}
// extractPlainText collects the text content of a node.
func extractPlainText(n ast.Node, source []byte) string {
var buf bytes.Buffer
_ = ast.Walk(n, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch t := node.(type) {
case *ast.Text:
buf.Write(t.Segment.Value(source))
case *ast.String:
buf.Write(t.Value)
}
return ast.WalkContinue, nil
})
return strings.TrimSpace(buf.String())
}

View File

@@ -0,0 +1,55 @@
package plaintext
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yuin/goldmark"
)
func render(t *testing.T, md string) string {
t.Helper()
var b []byte
buf := bytesBuffer{&b}
g := goldmark.New(goldmark.WithExtensions(Extender))
require.NoError(t, g.Convert([]byte(md), &buf))
return string(b)
}
// bytesBuffer is a tiny io.Writer so the test needs no extra imports.
type bytesBuffer struct{ b *[]byte }
func (w bytesBuffer) Write(p []byte) (int, error) {
*w.b = append(*w.b, p...)
return len(p), nil
}
func TestPlainText(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"strips bold and italic", "**bold** and *italic*", "bold and italic"},
{"link becomes text (url)", "[View in SigNoz](https://signoz.io/alert)", "View in SigNoz (https://signoz.io/alert)"},
{"bold label kept, marker dropped", "**Alert:** name (critical)", "Alert: name (critical)"},
{"strikethrough stripped", "~~gone~~", "gone"},
{"inline code unwrapped", "run `foo bar`", "run foo bar"},
{"paragraphs separated by blank line", "one\n\ntwo", "one\n\ntwo"},
{"unordered list", "- a\n- b", "- a\n- b"},
{"ordered list keeps numbering", "1. a\n2. b", "1. a\n2. b"},
{"nested list indents under parent", "- a\n - b", "- a\n - b"},
{"fenced code block unwrapped", "```go\nx := 1\n```", "x := 1\n"},
{"table flattens to pipe-separated rows", "| h1 | h2 |\n|---|---|\n| a | b |\n| c | d |", "h1 | h2\na | b\nc | d"},
{"autolink kept as bare url", "see <https://signoz.io>", "see https://signoz.io"},
{"inline raw html dropped", "a <b>bold</b> word", "a bold word"},
{"html block dropped", "before\n\n<div>markup</div>\n\nafter", "before\n\nafter"},
{"hard break becomes newline", "one \ntwo", "one\ntwo"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
assert.Equal(t, c.want, render(t, c.in))
})
}
}

View File

@@ -216,13 +216,20 @@ func (PostableChannel) JSONSchema() (jsonschema.Schema, error) {
schema.WithRequired("name")
var oneOf []jsonschema.SchemaOrBool
// Walk both halves: native fields on Receiver, upstream on the embed.
seen := map[string]struct{}{}
// Walk both halves: native fields on Receiver, upstream on the embed. A native
// field can shadow an upstream one with the same tag (e.g. jira_configs), so
// dedupe to avoid emitting two identical oneOf branches.
collect := func(t reflect.Type) {
for i := 0; i < t.NumField(); i++ {
jsonTag := strings.Split(t.Field(i).Tag.Get("json"), ",")[0]
if !strings.HasSuffix(jsonTag, "_configs") {
continue
}
if _, ok := seen[jsonTag]; ok {
continue
}
seen[jsonTag] = struct{}{}
branch := (&jsonschema.Schema{}).WithRequired(jsonTag)
oneOf = append(oneOf, branch.ToSchemaOrBool())
}

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