Compare commits

..

11 Commits

Author SHA1 Message Date
Nikhil Soni
7b0989c4bb revert: skip saved views whose data no longer decodes
This reverts commit f5713dc1ba, keeping it in history so the approach can be
picked up again later. The list-resilience change is more machinery than is
warranted right now -- migration 113 in this PR is what fixes the data we
actually have.

Assisted-by: Claude Opus 5
2026-08-13 19:17:38 +05:30
Nikhil Soni
f5713dc1ba fix(saved-views): skip saved views whose data no longer decodes
saved_view.data was scanned straight into a typed SavedViewData, so bun
decoded the spec during the scan itself. The spec decode is strict --
QueryEnvelope rejects unknown fields and unknown query types, and RequestType
rejects values outside its enum -- so a single row written by an older build
failed the whole scan, and List wrapped it as an internal error, hiding every
other view in the org.

List now scans into RawStorableSavedView, which keeps the data as text, and the
module decodes per row, logging and skipping a view that no longer decodes.
Create, get, update and delete keep using StorableSavedView unchanged.

Assisted-by: Claude Opus 5
2026-08-13 19:10:40 +05:30
Nikhil Soni
08607fac9d fix(saved-views): recover legacy-shaped selectedFields entries
Historical saved views still store selectedFields in the pre-v5 shape
(key/dataType/type). Migration 111 only rewrote rows whose spec failed to
unmarshal, and legacy-shaped entries unmarshal cleanly into zero-valued
TelemetryFieldKeys, so those rows were left on disk untouched and now read
back with empty name/signal/fieldContext/fieldDataType.

Add migration 113 to remap key -> name, type -> fieldContext and
dataType -> fieldDataType, including the legacy enum spellings that have no
current alias (spanSearchScope, array(x)). Entries with neither name nor key
are dropped. Already-valid entries pass through as raw bytes so description
and unit survive.

Also require selectedFields[].name in SavedViewSpec.Validate so neither API
version can write nameless entries again.

Assisted-by: Claude Opus 5
2026-08-13 18:28:20 +05:30
Vinicius Lourenço
013e631c68 fix(translate): do not crash on use google translate (#12466)
<!--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

This PR aims to fix the issues we have with translation, today when we
enable the translation, some parts of the app crash due to how the react
works and how the translation works.

In simple works, if you have: `<button>{(var ? 'text' : 'other text')}
{icon}` it will crash because the React will represent the `text` and
`other text` as `TextNode`, and when translate is performed, it changes
the parent of this element to `font` and causes the react to be "blind",
and when trying to delete the element, it cannot find.

> Read
https://martijnhols.nl/blog/everything-about-google-translate-crashing-react
to understand more

There's many fixes that includes ignore the errors and let the app with
invalid data, or actually go ahead and find the places with this pattern
and avoid them.

I kinda mixed two approaches, I introduced a new plugin based on
https://github.com/getcouped/eslint-plugin-react-google-translate/ but
adapted a little bit for our necessity and for our codebase (with oxc).
If we only use this plugin to find and fix the places, we will find most
of the issues crashing the app, but not all of them.

Why not all? Because even our component library is not safe enough for
google translate, eg: https://github.com/SigNoz/components/issues/351

So, I also included https://npmx.dev/package/translation-resilience,
this lib has another approach to fix the issue with the TextNode:

```
Instead of swallowing errors, this shim puts the original text nodes **back** the moment the renderer touches them:

1. A document-wide `MutationObserver` recognizes translation's displacement pattern (merge, wrap, remove — a pattern renderer commits never produce) and tracks each replaced text run as a *displacement group*: the ordered renderer-owned originals with their pre-translation values, plus the wrapper nodes currently standing in for them.
2. Patched `Node.prototype.removeChild` / `insertBefore` / `appendChild` and the `nodeValue` / `data` setters detect operations on displaced text nodes and first **restore the group** — originals go back into the wrappers' position, wrappers are removed — then let the native operation proceed on a consistent tree.
3. The translator's own observer notices the restored (now updated) text and re-translates it, so the user sees fresh, translated content. The loop is self-healing: update → restore → re-translate.

The result: no crashes, **and** live data keeps updating on translated pages — in the visitor's language.
```

We could keep the lib only and no plugin? Yes, but I want to make our
app more resilient without need the help of the lib, so we can continue
to adopt/fix places that has the pattern to crash the app, and
eventually, we can remove the lib because our app is resilient enough.

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

Closes https://github.com/SigNoz/platform-pod/issues/2912

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


https://github.com/user-attachments/assets/0225464b-1afe-46ad-afe7-25f79e25201a

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

This lib has a performance cost but the lib only enable itself when it
detects the translation is enabled, so our app (and users) should not
see/perceive any performance cost due to this lib. But again, this is
another reason to slowly adapt and fix all places that offers a
potential problem to google translate.
2026-08-13 08:23:47 +00:00
Abhi kumar
535a29adbf fix(query-builder): let tag add-on fields grow instead of spilling their tags (#12541)
<!--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

- Adding a few values to **Group By** made the tags wrap onto a second
row that rendered outside the field, on top of the add-on toggles below
it. **Order By** and the formula Order By row had the same bug.
- The add-on field pinned the antd select and its selector to `height:
36px`, so it could never grow. Both are `min-height: 36px` now —
single-line selects keep the same 36px row, tag selects grow with their
rows.

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

Before
<img width="1742" height="117" alt="image"
src="https://github.com/user-attachments/assets/12dfccbe-d087-4857-9bd0-3b6dfa0a1da1"
/>

After
<img width="1778" height="164" alt="image"
src="https://github.com/user-attachments/assets/abeb0b38-b0ce-4749-bf1c-32fc865833b9"
/>

#### Issues Closed
Closes https://github.com/SigNoz/pulse-pod/issues/270


<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

- Broke in #11992, which put `height: 36px` on `.ant-select-selector`
and moved the field's border onto it. The same `height` on the root
`.ant-select` predates that but never applied, because
`GroupByFilter`/`OrderByFilter` pass an inline `height: 100%` — antd was
left to size the selector from its content, so the field used to grow as
tags wrapped.
- Checked in a headless-Chromium repro of the field using antd 5.11's
select rules: 12 tags in a ~900px field hang 20px below the box on
`main`, and sit inside it with this change.
2026-08-13 08:16:25 +00:00
Tushar Vats
3d5aab744d fix(logs): stringify body_v2 in the v3 logs select (#12534)
#### Description

Two commits: a clean revert of #12523, then a reland with the body
stringified.

**Why the revert.** #12523 selected `body_v2 as body` for orgs on JSON
bodies. ClickHouse resolves identifiers in `WHERE` against SELECT
aliases, and the v3 filter builder emits a bare `body` (`body != ''` for
exists, `lower(body) like …` for contains), so every body filter started
running against the JSON column and failed with `Code: 117 … Cannot
parse JSON object here: while converting '' to JSON`. The pipelines
preview always sends the pipeline's filter, so picking sample logs by
body errored outright.

**What the reland changes.** The select is `toString(body_v2) as body`,
so the alias stays a String and those filters compare against the body
text again. As a bonus they now actually match — before #12523 they ran
against the legacy `body` column, which the collector writes empty for
these orgs, so they silently matched nothing. The JSON column decoding
#12523 added to `GetListResultV3` is not relanded: nothing selects a
JSON column on this path now, and it failed the entire query on a row it
could not unmarshal rather than just that row.

Reproduced directly against ClickHouse:

```sql
SELECT body_v2 AS body FROM signoz_logs.distributed_logs_v2 WHERE body != '' LIMIT 1;
-- Code: 117. DB::Exception: Cannot parse JSON object here: while converting '' to JSON(...)

SELECT toString(body_v2) AS body FROM signoz_logs.distributed_logs_v2 WHERE body != '' LIMIT 1;
-- {"level":"error","message":"json log line","user":"alice"}
```

#### Additional Information

Verified end to end on a local stack (devenv ClickHouse + a collector
with `body_json_enabled`, `use_json_body` on): `body EXISTS` and `body
CONTAINS` both return rows, and the body comes back as the stringified
JSON.

v5 is unaffected — its field mapper builds a real JSON expression
instead of emitting a bare `body`, so `body EXISTS` there already worked
and still returns object bodies.

The response shape for v3 is a JSON string rather than the object #12523
returned. Consumers that need structure can parse it; the pipelines
preview endpoint accepts either, since it types the log body as `any`
and re-parses through the `normalize` pipeline.

Known gaps left alone, since they predate this or need the filter
builder to become JSON-aware: aggregation and group-by queries still
read the empty legacy `body` column (only the list select carries the
alias), body filters cannot use the `body_v2` skip indexes while
stringified, and the v4 endpoint never sets the flag.
2026-08-13 08:04:42 +00:00
Gaurav Tewari
a8309a1a02 test(qb): add integration tests for recent searches dropdown (#12045)
#### Description

Adds integration tests for the Recent Searches dropdown in the query
builder search editor.

What's covered:

- A saved recent shows up under "Recent searches" on focus
- Recents filter by substring as you type
- Recents stay partitioned by signal — a `traces` recent never leaks
into the `logs` editor
- A recent identical to what's already typed is excluded
- Clicking a recent applies the whole expression and closes the popup
- The dropdown caps at `RECENTS_DISPLAY_CAP` entries, newest first
(asserted as a full ordered array)
- The per-entry delete button removes the recent from both the dropdown
and the store, without applying it

#### Issues closed by this PR

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

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-08-13 08:04:03 +00:00
Aditya Singh
897036968c feat(trace-details): add analytics events to span percentile flow (#12540)
<!--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
Instrument the span percentile widget with product analytics: 

- panel toggle
- time-range change
- resource-attributes selector toggle
- attribute selection change. 

Events go through the existing useTraceDetailLogEvent hook so view and
traceId are injected.

<!--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/5908
2026-08-13 07:56:36 +00:00
Vinicius Lourenço
55ef5fbc3c fix(infrastructure-monitoring-namespaces): wrong division for available/desired and use lastest instead of avg (#12429)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
## Pull Request

---

### 📄 Summary
> Why does this change exist?  
> What problem does it solve, and why is this the right approach?

This follows the same pattern as
https://github.com/SigNoz/signoz/pull/11681 to use `latest` instead of
`avg`, and also fixes the calculation of `util %` that was suppose to be
`desired/available * 100` instead of current value `available/desired`.

#### Screenshots / Screen Recordings (if applicable)
> Include screenshots or screen recordings that clearly show the
behavior before the change and the result after the change. This helps
reviewers quickly understand the impact and verify the update.

Before:

<img width="857" height="364" alt="image"
src="https://github.com/user-attachments/assets/949db1a8-c27d-41da-8573-398a4d53af24"
/>

After:

<img width="851" height="336" alt="image"
src="https://github.com/user-attachments/assets/5181849a-28e4-45f8-b7a5-0d611b5ae02e"
/>

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

Closes https://github.com/SigNoz/pulse-pod/issues/210

---

###  Change Type
_Select all that apply_

- [ ]  Feature
- [x] 🐛 Bug fix
- [ ] ♻️ Refactor
- [ ] 🛠️ Infra / Tooling
- [ ] 🧪 Test-only

---

### 🧪 Testing Strategy
> How was this change validated?

- Tests added/updated: No
- Manual verification: Yes
- Edge cases covered: -

---

### ⚠️ Risk & Impact Assessment
> What could break? How do we recover?

- Blast radius: Infrastructure Monitoring - Namespaces
- Potential regressions: None
- Rollback plan: Revert this commit

---

### 📝 Changelog
> Fill only if this affects users, APIs, UI, or documented behavior  
> Use **N/A** for internal or non-user-facing changes

| Field | Value |
|------|-------|
| Deployment Type | Cloud / OSS / Enterprise |
| Change Type | Bug Fix |
| Description | We updated the table for Desired (pods) inside the
Namespace Details on Infrastructure Monitoring to correctly show the
`util %`. |

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [x] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered
2026-08-13 05:52:36 +00:00
Shivam Gupta
49749626dc fix(onboarding): list multi-signal data sources under every signal they support (#12522)
#### Description

- Some data sources ship a single doc that sets up two or three signals,
but carried only one tag, so they showed up in exactly one section of
the picker. Searching `temporal` surfaced it only under APM/Traces even
though both Temporal docs configure traces, metrics and logs.
- Tagged them with every signal their doc actually configures, so they
list under each matching section — the same way `Deno` already does. No
UI changes needed: `groupDataSourcesByTags` already fans an entry out
across its tags.

| entry | was | now |
| --- | --- | --- |
| Temporal | `apm/traces` | `apm/traces`, `logs`, `metrics` |
| Nginx - OpenTelemetry (was "Nginx - Tracing") | `apm/traces` |
`apm/traces`, `logs`, `metrics` |
| OpenTelemetry eBPF (OBI) | `apm/traces` | `apm/traces`, `metrics` |
| DBOS | `apm/traces` | `apm/traces`, `logs` |
| Cloudflare Workers | `apm/traces` | `apm/traces`, `logs` |

- "Nginx - Tracing" is renamed to "Nginx - OpenTelemetry" since it no
longer lists only under traces, and to stay distinct from the existing
built-in Nginx integration entry.

#### Additional Information

- All 82 docs behind the 70 single-signal-tagged entries were read to
decide this; the other 77 are genuinely single-signal. Every language
APM doc explicitly sets `OTEL_METRICS_EXPORTER=none` /
`OTEL_LOGS_EXPORTER=none`, and the matching metrics docs set
`OTEL_TRACES_EXPORTER=none` — so splits like `Java` / `Java logs` /
`Java Metrics` are correct as they stand.
- Left unchanged, but worth a second opinion: the logs docs for Java,
Python, Node.js (Pino/Winston/Bunyan) and Golang (Logrus/Zerolog) run
auto-instrumentation that emits traces, but only ever mention traces to
tell you how to switch them off. Read as logs-only here.
2026-08-13 04:03:15 +00:00
Pandey
061eb1f867 chore(deps): bump clickhouse-sql-parser to v0.5.6 (#12536)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
### Description

Bumps `github.com/AfterShip/clickhouse-sql-parser` from v0.5.5 to
v0.5.6.

- v0.5.6 parses a parenthesized left operand of a set operator (upstream
https://github.com/AfterShip/clickhouse-sql-parser/pull/312), e.g.
`(SELECT 1) UNION ALL (SELECT 2)`.
- Moves the three now-passing parenthesized set-operation cases into the
pass table in `clickhouse_sql_test.go` as regression canaries.
- Records the outstanding `NULLS FIRST|LAST` ORDER BY gap in the
known-gap table — the parser still rejects it, so it stays tracked until
fixed upstream.
2026-08-12 19:25:17 +00:00
39 changed files with 1946 additions and 581 deletions

View File

@@ -58,7 +58,6 @@ jobs:
- querierai
- rawexportdata
- promqlconformance
- promapiconformance
- querierauthz
- role
- rootuser

View File

@@ -487,8 +487,11 @@
// Simplifies boolean returns
"sonarjs/prefer-while": "error",
// Suggests while loops over for loops
"sonarjs/elseif-without-else": "off"
"sonarjs/elseif-without-else": "off",
// Requires final else in if-else-if chains (was disabled)
"signoz/no-conditional-text-nodes-with-siblings": "warn",
// Vendored from eslint-plugin-react-google-translate
"signoz/no-return-text-nodes": "warn"
},
"ignorePatterns": [
"src/parser/*.ts",

View File

@@ -16,6 +16,7 @@
"lint:generated": "oxlint ./src/api/generated --fix",
"lint:fix": "oxlint ./src --fix",
"lint:styles": "stylelint \"src/**/*.scss\"",
"test:plugins": "node --test \"plugins/__tests__/*.test.mjs\"",
"jest": "jest",
"jest:coverage": "jest --coverage",
"jest:watch": "jest --watch",
@@ -125,6 +126,7 @@
"rrule": "2.8.1",
"styled-components": "^5.3.11",
"timestamp-nano": "^1.0.0",
"translation-resilience": "^0.2.0",
"typescript": "5.9.3",
"uplot": "1.6.31",
"uuid": "14.0.1",

View File

@@ -0,0 +1,130 @@
# Plugin rule tests
Tests for the custom oxlint rules in `plugins/rules/`.
```bash
pnpm test:plugins
```
Runs on `node --test` rather than jest. The jest config is built for application
code — jsdom, ts-jest ESM transforms, a large `transformIgnorePatterns` wall —
and none of it applies to a suite whose only job is to shell out to the linter.
## Why it drives the real binary
Each case is written to a temp file and linted by the actual `oxlint` binary,
with every builtin category switched off so the only diagnostics that can appear
belong to the rule under test. Assertions therefore describe what CI enforces.
The alternative — walking the AST in-process — would need a stand-in for
oxlint's JS plugin AST. That AST is ESTree-shaped but not ESTree, and it carries
no type information, so a stand-in would drift from the runtime it claims to
model and the tests would certify behaviour that never happens.
All cases in a suite share one `oxlint` invocation and are mapped back by
filename. Per-case spawning costs roughly 80ms each; batching keeps both suites
together at around 250ms.
## Adding a suite
```js
import { ruleTester } from './rule-tester.mjs';
await ruleTester({
rule: 'no-navigator-clipboard',
valid: ['const x = 1;'],
invalid: [
{
code: 'navigator.clipboard.writeText("x");',
errors: [{ message: 'useCopyToClipboard', line: 1, column: 1 }],
},
],
});
```
`ruleTester` must be awaited at the top level — it loads the plugin and runs
`oxlint` before declaring the tests.
- `rule` — the key the plugin exports it under. `plugin` defaults to
`plugins/signoz.mjs`; pass a path relative to `frontend/` for another plugin.
- Cases are `.tsx` unless a `filename` gives another extension.
- `errors` takes a count or an array. Each entry may assert `message` (substring
or `RegExp`), `line` and `column`; omitted fields are not checked.
- `name` labels the case in the output and defaults to its first line of code.
- `output` asserts the source after suggestions are applied — see below.
- `todo` marks a case as a known defect — see below.
## Suggestions
Both Google Translate rules attach their wrap as a *suggestion*, not a fix, so
`--fix` leaves the code alone and `--fix-suggestions` applies it. The wrap is
`<span className="translate-safe">`, and `.translate-safe` is `display: contents`
in `src/styles.scss`: React owns an element that absorbs Translate's `<font>`
swap, while the box tree stays as it was, so a flex or grid parent still sees one
contiguous text run rather than a new item with its own `gap`.
It stays a suggestion because the element is still a DOM child even with no box:
`> *`, `:nth-child` and sibling selectors still count it, and a component that
inspects its children — `React.Children.map`, antd `Tooltip`, `Space` — sees an
element where a string used to be. That is what oxlint means by "May change
program behavior" in `--fix-suggestions --help`.
An invalid case carrying `output` is linted twice: once for diagnostics, and
once with `--fix-suggestions` over an untouched copy of the same files. The
second run costs one extra `oxlint` spawn per suite and only happens when at
least one case asks for it.
```js
{
code: "export const A = () => <div>{f ? 'a' : 'b'}<b/></div>;",
errors: 2,
output:
'export const A = () => <div>{f ? <span className="translate-safe">a</span> : <span className="translate-safe">b</span>}<b/></div>;',
}
```
Suggestions do not reformat, so a real run is `oxlint --fix-suggestions` then
`oxfmt`.
## Known defects
A case carrying `todo` asserts what the rule *should* do. It still runs, but a
failure is reported as a todo rather than failing the suite, so a bug can be
pinned as an executable spec instead of prose. Fixing the rule turns the todo
green; deleting the flag then makes it a regression guard.
Cases are prefixed `FP:` where the rule reports something it should not, `GAP:`
where it misses something it should catch, and `TYPE-AWARE:` where the miss is
only fixable once the linter can resolve types. Everything without a flag is a
characterisation test recording current behaviour.
The current todos:
**Gaps — constructs the rules never visit.** `isProblematicConditional` requires
a `JSXElement` parent, so a conditional inside a fragment is never inspected even
though the failure does not care about the parent's kind. `no-return-text-nodes`
listens only for `FunctionDeclaration` and reads the name off `node.id`, so
arrow-function components and anonymous default exports are invisible — this
codebase writes components as arrow functions, which is why that rule reports
nothing across `src`.
Class components are left out deliberately rather than pinned as a gap: there
are none in `src`.
**Type-aware gaps.** Upstream resolves branch types through
`@typescript-eslint/utils` and reports anything typed `string` or `number`.
oxlint's JS plugin runtime exposes no type information — `sourceCode.parserServices`
is always `{}` — so those code paths were removed rather than left dormant. The
`TYPE-AWARE:` todos record what they used to catch, and become the acceptance
criteria if oxlint ever hands types to JS plugins.
## Not a defect
Without types, `no-conditional-text-nodes-with-siblings` falls back to a callee
allowlist (`t`, `formatMessage`, `toString`, `toLocaleString`). Cases around
that allowlist pin its edges; widening it is the supported way to catch more
call expressions.
Both branches of a ternary are reported separately, so one fix can clear two
diagnostics. That inflates the count but every reported node is genuinely a text
node, so the cases assert both.

View File

@@ -0,0 +1,302 @@
import { ruleTester } from './rule-tester.mjs';
const CONDITIONAL = 'Conditionally rendered text nodes with siblings';
const PRECEDED = 'Text nodes which are preceded by a conditional expression';
await ruleTester({
rule: 'no-conditional-text-nodes-with-siblings',
valid: [
{
name: 'conditional text node without siblings',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? 'yes' : 'no'}\n\t</div>\n);",
},
{
name: 'boolean branches are not text',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? true : false}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'null branches are not text',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? null : null}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'element branches are already wrapped',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? <b>y</b> : <i>n</i>}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'text node before the conditional is safe',
code:
'export const A = () => (\n\t<div>\n\t\tleading text\n\t\t{flag && <b>y</b>}\n\t</div>\n);',
},
{
name: 'member expression on the condition side is not rendered',
code:
'export const A = () => (\n\t<div>\n\t\t{obj.name && <b>y</b>}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'binary comparison on the condition side is not rendered',
code:
"export const A = () => (\n\t<div>\n\t\t{obj.name === 'x' && <b>y</b>}\n\t\t<span>x</span>\n\t</div>\n);",
},
// An empty string renders no text node at all, so there is nothing for
// Google Translate to wrap and nothing for React to lose. Reporting it used
// to be the rule's most common false positive.
{
name: 'element branch with an empty-string fallback',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? <b>Free Trial</b> : ''}\n\t\t<span>s</span>\n\t</div>\n);",
},
{
name: 'both branches empty',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? '' : ''}\n\t\t<span>s</span>\n\t</div>\n);",
},
{
name: 'logical and with an empty-string right-hand side',
code:
"export const A = () => (\n\t<div>\n\t\t{flag && ''}\n\t\t<span>s</span>\n\t</div>\n);",
},
// A template literal is checked the same way as the quoted form, so `{' '}`
// and ``{` `}`` agree.
{
name: 'empty template literal branch',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? `` : ''}\n\t\t<span>s</span>\n\t</div>\n);",
},
{
name: 'whitespace-only template literal branch is skipped',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? ` ` : <b>x</b>}\n\t\t<span>s</span>\n\t</div>\n);',
},
],
invalid: [
{
name: 'string literal branches with an element sibling',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? 'yes' : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 19 },
],
output:
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">yes</span> : <span className="translate-safe">no</span>}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'logical and with a string right-hand side',
code:
"export const A = () => (\n\t<div>\n\t\t{flag && 'yes'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [{ message: CONDITIONAL, line: 3, column: 12 }],
},
{
name: 'numeric literals render as text',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? 1 : 2}\n\t\t<span>x</span>\n\t</div>\n);',
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 15 },
],
output:
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">{1}</span> : <span className="translate-safe">{2}</span>}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'template literal branch',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? `yes ${n}` : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 24 },
],
output:
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">{`yes ${n}`}</span> : <span className="translate-safe">no</span>}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'member expression branch',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? obj.name : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 22 },
],
output:
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">{obj.name}</span> : <span className="translate-safe">no</span>}\n\t\t<span>x</span>\n\t</div>\n);',
},
{
name: 'a string needing escapes stays inside braces',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? "it\'s" : <b>x</b>}\n\t\t<span>s</span>\n\t</div>\n);',
errors: [{ message: CONDITIONAL, line: 3, column: 11 }],
output:
'export const A = () => (\n\t<div>\n\t\t{flag ? <span className="translate-safe">{"it\'s"}</span> : <b>x</b>}\n\t\t<span>s</span>\n\t</div>\n);',
},
{
name: 'optional chaining branch',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? obj?.deep?.name : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 29 },
],
},
{
name: 'nested ternary reports every text branch',
code:
"export const A = () => (\n\t<div>\n\t\t{a ? (b ? 'x' : 'y') : 'z'}\n\t\t<span>s</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 13 },
{ message: CONDITIONAL, line: 3, column: 19 },
{ message: CONDITIONAL, line: 3, column: 26 },
],
},
{
name: 'static text following a conditional',
code:
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\ttrailing text\n\t</div>\n);',
errors: [{ message: PRECEDED, line: 3, column: 21 }],
// Only the visible run is wrapped; the surrounding newlines and tabs are
// formatting and must stay outside the element.
output:
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t<span className="translate-safe">trailing text</span>\n\t</div>\n);',
},
{
name: 'conditional text plus trailing static text reports both kinds',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? 'a' : 'b'}\n\t\tliteral tail\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 17 },
{ message: PRECEDED, line: 3, column: 21 },
],
},
// The callee allowlist below is the untyped fallback. Without type
// information the rule can only recognise known string-returning helpers,
// so `t()` and `formatMessage()` are reported while an arbitrary call is
// not. These cases pin that boundary.
{
name: 't() branch is reported via the callee allowlist',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? t('key') : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 22 },
],
},
{
name: 'formatMessage() branch is reported via the callee allowlist',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? formatMessage({id:'k'}) : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 37 },
],
},
{
name: 'arbitrary call is not recognised, only the literal branch reports',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? getString() : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [{ message: CONDITIONAL, line: 3, column: 25 }],
},
{
name: 'bare identifier is not recognised, only the literal branch reports',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? name : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [{ message: CONDITIONAL, line: 3, column: 18 }],
},
{
name: 'toString() branch is reported',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? val.toString() : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 28 },
],
},
{
name: 'toLocaleString() branch is reported',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? val.toLocaleString() : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 34 },
],
},
{
name: 't() in its own container following a conditional',
code:
"export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t{t('key')}\n\t</div>\n);",
errors: [{ message: PRECEDED, line: 4, column: 4 }],
},
{
name: 'toString() in its own container following a conditional',
code:
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t{val.toString()}\n\t</div>\n);',
errors: [{ message: PRECEDED, line: 4, column: 4 }],
// The whole container is replaced, so the result is not `{<span>{…}</span>}`.
output:
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t<span className="translate-safe">{val.toString()}</span>\n\t</div>\n);',
},
{
name: 'whitespace-only string branch is skipped, the other branch reports',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? ' ' : 'x'}\n\t\t<span>s</span>\n\t</div>\n);",
errors: [{ message: CONDITIONAL, line: 3, column: 17 }],
},
{
name: 'template literal holding an expression is not blank',
code:
'export const A = () => (\n\t<div>\n\t\t{flag ? `${n}` : <b>x</b>}\n\t\t<span>s</span>\n\t</div>\n);',
errors: [{ message: CONDITIONAL, line: 3, column: 11 }],
},
// Upstream resolves branch types through `@typescript-eslint/utils` and
// reports anything typed `string` or `number`. oxlint's JS plugin runtime
// exposes no type information, so those paths were dropped and only the
// callee allowlist remains. Kept as todos: if oxlint ever hands types to JS
// plugins these become the acceptance criteria.
{
todo: 'needs type information to know the call returns a string',
name: 'TYPE-AWARE: call returning a string',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? getString() : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 25 },
],
},
{
todo: 'needs type information to know the identifier is a string',
name: 'TYPE-AWARE: identifier holding a string',
code:
"export const A = () => (\n\t<div>\n\t\t{flag ? name : 'no'}\n\t\t<span>x</span>\n\t</div>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 18 },
],
},
{
todo: 'needs type information to know the identifier is a string',
name: 'TYPE-AWARE: string identifier following a conditional',
code:
'export const A = () => (\n\t<div>\n\t\t{flag && <b>y</b>}\n\t\t{label}\n\t</div>\n);',
errors: [{ message: PRECEDED, line: 4, column: 4 }],
},
// `isChildOfJSXElement` matches only `JSXElement`, so a fragment parent is
// never inspected. The Google Translate failure does not care whether the
// parent is an element or a fragment.
{
todo: 'fragment parents are never inspected',
name: 'GAP: conditional text with a sibling inside a fragment',
code:
"export const A = () => (\n\t<>\n\t\t{flag ? 'yes' : 'no'}\n\t\t<span>x</span>\n\t</>\n);",
errors: [
{ message: CONDITIONAL, line: 3, column: 11 },
{ message: CONDITIONAL, line: 3, column: 19 },
],
},
],
});

View File

@@ -0,0 +1,162 @@
import { ruleTester } from './rule-tester.mjs';
const RETURNS_TEXT = 'React components should avoid returning text nodes';
await ruleTester({
rule: 'no-return-text-nodes',
valid: [
{
name: 'lowercase function is not a component',
code: "export function foo() {\n\treturn 'text';\n}",
},
{
name: 'returning JSX',
code: 'export function Foo() {\n\treturn <div>hi</div>;\n}',
},
{
name: 'returning null',
code: 'export function Foo() {\n\treturn null;\n}',
},
{
name: 'returning boolean',
code: 'export function Foo() {\n\treturn true;\n}',
},
{ name: 'bare return', code: 'export function Foo() {\n\treturn;\n}' },
{
name: 'returning a variable is not a literal',
code: "export function Foo() {\n\tconst s = 'x';\n\treturn s;\n}",
},
{
name: 'lowercase nested function inside a component',
code:
"export function Foo() {\n\tfunction helper() {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
},
{
// The repo has no class components, so this is out of scope rather than
// a gap worth closing.
name: 'class method',
code: "export class Foo {\n\trender() {\n\t\treturn 'text';\n\t}\n}",
},
],
invalid: [
{
name: 'string literal',
code: "export function Foo() {\n\treturn 'text';\n}",
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
output:
'export function Foo() {\n\treturn <span className="translate-safe">{\'text\'}</span>;\n}',
},
{
// JSX does not parse in a `.ts` file, so no suggestion is offered there.
name: 'string literal in a non-JSX file',
filename: 'case.ts',
code: "export function Foo() {\n\treturn 'text';\n}",
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
output: "export function Foo() {\n\treturn 'text';\n}",
},
{
name: 'numeric literal',
code: 'export function Foo() {\n\treturn 42;\n}',
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
},
{
name: 'template literal',
code: 'export function Foo() {\n\treturn `text ${x}`;\n}',
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
},
{
name: 'inside an if consequent',
code:
"export function Foo() {\n\tif (a) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
{
name: 'inside an else block',
code:
"export function Foo() {\n\tif (a) {\n\t\treturn <div/>;\n\t} else {\n\t\treturn 'x';\n\t}\n}",
errors: [{ message: RETURNS_TEXT, line: 5, column: 3 }],
},
{
name: 'inside an else-if chain',
code:
"export function Foo() {\n\tif (a) {\n\t\treturn <div/>;\n\t} else if (b) {\n\t\treturn 'x';\n\t}\n\treturn null;\n}",
errors: [{ message: RETURNS_TEXT, line: 5, column: 3 }],
},
{
name: 'inside a switch case',
code:
"export function Foo() {\n\tswitch (a) {\n\t\tcase 1:\n\t\t\treturn 'x';\n\t\tdefault:\n\t\t\treturn <div/>;\n\t}\n}",
errors: [{ message: RETURNS_TEXT, line: 4, column: 4 }],
},
{
name: 'inside try, catch and finally',
code:
"export function Foo() {\n\ttry {\n\t\treturn 'a';\n\t} catch {\n\t\treturn 'b';\n\t} finally {\n\t\treturn 'c';\n\t}\n}",
errors: [
{ message: RETURNS_TEXT, line: 3, column: 3 },
{ message: RETURNS_TEXT, line: 5, column: 3 },
{ message: RETURNS_TEXT, line: 7, column: 3 },
],
},
{
name: 'inside a for loop',
code:
"export function Foo() {\n\tfor (let i = 0; i < 3; i++) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
{
name: 'inside a for-of loop',
code:
"export function Foo() {\n\tfor (const i of list) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
{
name: 'inside a for-in loop',
code:
"export function Foo() {\n\tfor (const k in obj) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
{
name: 'inside a while loop',
code:
"export function Foo() {\n\twhile (a) {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
{
name: 'inside a do-while loop',
code: "export function Foo() {\n\tdo {\n\t\treturn 'x';\n\t} while (a);\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
{
name: 'capitalised nested function is treated as a component',
code:
"export function Foo() {\n\tfunction Helper() {\n\t\treturn 'x';\n\t}\n\treturn <div/>;\n}",
errors: [{ message: RETURNS_TEXT, line: 3, column: 3 }],
},
// The rule listens only for `FunctionDeclaration` and reads the component
// name off `node.id`. Everything below returns a text node from something
// React renders as a component, and none of it is reported. This codebase
// writes components as arrow functions, which is why the rule currently
// finds nothing in `src`.
{
todo: 'arrow function components are never visited',
name: 'GAP: arrow component with an expression body',
code: "export const Foo = () => 'text';",
errors: 1,
},
{
todo: 'arrow function components are never visited',
name: 'GAP: arrow component with a block body',
code: "export const Foo = () => {\n\treturn 'text';\n};",
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
},
{
todo: 'anonymous declarations have no node.id to read a name from',
name: 'GAP: anonymous default-exported component',
code: "export default function () {\n\treturn 'text';\n}",
errors: [{ message: RETURNS_TEXT, line: 2, column: 2 }],
},
],
});

View File

@@ -0,0 +1,257 @@
/**
* Test harness for oxlint JS plugins.
*
* Rules are exercised through the real `oxlint` binary rather than a hand-rolled
* AST walker, so what the tests assert is exactly what CI enforces. oxlint's JS
* plugin AST is close to ESTree but not identical, and it exposes no type
* information, so any in-process fake would drift from the real runtime.
*
* All cases in a suite are written to one temp directory and linted in a single
* oxlint invocation, then mapped back by filename. Spawning per case costs ~80ms
* each; batching keeps a full suite under a second.
*/
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath, pathToFileURL } from 'node:url';
const FRONTEND_DIR = path.resolve(fileURLToPath(import.meta.url), '../../..');
const OXLINT_BIN = path.join(FRONTEND_DIR, 'node_modules/.bin/oxlint');
// oxlint enables its default categories unless every one is switched off, and a
// stray builtin diagnostic would be indistinguishable from the rule under test.
const CATEGORIES_OFF = {
correctness: 'off',
suspicious: 'off',
pedantic: 'off',
perf: 'off',
style: 'off',
restriction: 'off',
nursery: 'off',
};
function normaliseCase(entry, index) {
const testCase = typeof entry === 'string' ? { code: entry } : entry;
const extension = testCase.filename
? path.extname(testCase.filename).slice(1)
: 'tsx';
return {
...testCase,
index,
basename: `case-${String(index).padStart(3, '0')}.${extension}`,
};
}
function diagnosticFilename(diagnostic) {
const raw = diagnostic.filename ?? '';
const asPath = raw.startsWith('file://') ? fileURLToPath(raw) : raw;
return path.basename(asPath);
}
function toError(diagnostic) {
const span = diagnostic.labels?.[0]?.span;
return {
message: diagnostic.message,
line: span?.line,
column: span?.column,
};
}
function runOxlint(dir, configPath, extraArgs = []) {
const args = ['--config', configPath, '--format', 'json', ...extraArgs, '.'];
try {
return execFileSync(OXLINT_BIN, args, {
cwd: dir,
encoding: 'utf8',
// A rule that reports on every case can produce a lot of output.
maxBuffer: 64 * 1024 * 1024,
});
} catch (error) {
// oxlint exits non-zero whenever it reports a diagnostic, which is the
// expected outcome for every `invalid` case.
if (typeof error.stdout === 'string' && error.stdout.trim() !== '') {
return error.stdout;
}
throw new Error(`oxlint failed to run:\n${error.stderr || error.message}`, {
cause: error,
});
}
}
function writeSuite(dir, cases, { pluginPath, ruleId }) {
for (const testCase of cases) {
const target = path.join(dir, testCase.basename);
mkdirSync(path.dirname(target), { recursive: true });
writeFileSync(target, testCase.code);
}
const configPath = path.join(dir, '.oxlintrc.json');
writeFileSync(
configPath,
JSON.stringify({
jsPlugins: [pluginPath],
categories: CATEGORIES_OFF,
rules: { [ruleId]: 'error' },
}),
);
return configPath;
}
/**
* Lints every case in one pass, and applies suggestions in a second pass over an
* untouched copy when any case declares `output`.
*
* @returns {{errors: Map<string, object[]>, outputs: Map<string, string>}}
*/
function lintCases(cases, options) {
const root = mkdtempSync(path.join(tmpdir(), 'oxlint-rule-tester-'));
try {
const lintDir = path.join(root, 'lint');
mkdirSync(lintDir);
const report = JSON.parse(
runOxlint(lintDir, writeSuite(lintDir, cases, options)),
);
const errors = new Map(cases.map((testCase) => [testCase.basename, []]));
for (const diagnostic of report.diagnostics ?? []) {
const bucket = errors.get(diagnosticFilename(diagnostic));
// oxlint reports config-level problems without a filename; surfacing
// them as a suite failure beats silently testing nothing.
if (!bucket) {
throw new Error(`Unexpected diagnostic: ${diagnostic.message}`);
}
bucket.push(toError(diagnostic));
}
const outputs = new Map();
if (cases.some((testCase) => testCase.output !== undefined)) {
const fixDir = path.join(root, 'fix');
mkdirSync(fixDir);
runOxlint(fixDir, writeSuite(fixDir, cases, options), ['--fix-suggestions']);
for (const testCase of cases) {
outputs.set(
testCase.basename,
readFileSync(path.join(fixDir, testCase.basename), 'utf8'),
);
}
}
return { errors, outputs };
} finally {
rmSync(root, { recursive: true, force: true });
}
}
function assertMessage(actual, expected, label) {
if (expected instanceof RegExp) {
assert.match(actual, expected, label);
} else {
assert.ok(
actual.includes(expected),
`${label}\n expected message to contain: ${expected}\n actual: ${actual}`,
);
}
}
function assertErrors(actual, expected, code) {
const context = `\n--- code ---\n${code}\n--- reported ---\n${JSON.stringify(actual, null, 2)}`;
if (typeof expected === 'number') {
assert.equal(actual.length, expected, `error count${context}`);
return;
}
assert.equal(actual.length, expected.length, `error count${context}`);
expected.forEach((want, i) => {
const got = actual[i];
if (want.message !== undefined) {
assertMessage(got.message, want.message, `error[${i}] message${context}`);
}
if (want.line !== undefined) {
assert.equal(got.line, want.line, `error[${i}] line${context}`);
}
if (want.column !== undefined) {
assert.equal(got.column, want.column, `error[${i}] column${context}`);
}
});
}
/**
* Declares a suite for one rule.
*
* A case carrying `todo` asserts the behaviour the rule *should* have. It still
* runs, but a failure is reported as a todo instead of failing the suite, so a
* known bug can be pinned as an executable spec. Delete the flag once the rule
* is fixed and the case starts guarding the fix.
*
* An invalid case carrying `output` also asserts the source after
* `--fix-suggestions` has been applied.
*
* @param {object} options
* @param {string} options.rule - rule name as exported by the plugin
* @param {string} [options.plugin] - path to the plugin, relative to `frontend/`
* @param {Array<string | {code: string, name?: string, filename?: string, todo?: string}>} options.valid
* @param {Array<{code: string, name?: string, filename?: string, todo?: string, output?: string, errors: number | Array<{message?: string | RegExp, line?: number, column?: number}>}>} options.invalid
*/
export async function ruleTester({
rule,
plugin = 'plugins/signoz.mjs',
valid = [],
invalid = [],
}) {
const pluginPath = path.join(FRONTEND_DIR, plugin);
const { default: pluginModule } = await import(pathToFileURL(pluginPath));
assert.ok(
pluginModule.rules?.[rule],
`plugin ${plugin} does not export a rule named "${rule}"`,
);
const ruleId = `${pluginModule.meta.name}/${rule}`;
const validCases = valid.map(normaliseCase);
const invalidCases = invalid.map((entry, i) =>
normaliseCase(entry, valid.length + i),
);
const { errors, outputs } = lintCases([...validCases, ...invalidCases], {
pluginPath,
ruleId,
});
const declare = (t, testCase, expected) => {
const label = testCase.name ?? testCase.code.trim().split('\n')[0];
return t.test(label, { todo: testCase.todo }, () => {
assertErrors(errors.get(testCase.basename), expected, testCase.code);
if (testCase.output !== undefined) {
assert.equal(
outputs.get(testCase.basename),
testCase.output,
`suggestion output\n--- code ---\n${testCase.code}`,
);
}
});
};
test(ruleId, async (t) => {
await t.test('valid', async (t) => {
for (const testCase of validCases) {
await declare(t, testCase, 0);
}
});
await t.test('invalid', async (t) => {
for (const testCase of invalidCases) {
await declare(t, testCase, testCase.errors);
}
});
});
}

View File

@@ -0,0 +1,313 @@
/**
* Rule: no-conditional-text-nodes-with-siblings
*
* Conditionally rendered text nodes with siblings should be wrapped in an
* element (for example a `<span>`), otherwise Google Translate causes a browser
* error. Translate replaces the text node with a `<font>` wrapper, React still
* holds a reference to the original node, and the next render throws on
* `removeChild`.
*
* Adapted from https://github.com/getcouped/eslint-plugin-react-google-translate
* (v1.0.4). The upstream rule resolves branch types through
* `@typescript-eslint/utils`; oxlint's JS plugin runtime exposes no type
* information, so those paths are dropped and call expressions are matched
* against the allowlist below instead.
*/
// Calls known to render as text. Without types this is the only way to
// recognise a string-returning call; widen it to catch more helpers.
const TEXT_RETURNING_CALLEES = new Set(['formatMessage', 't']);
const STRINGIFY_METHODS = new Set(['toString', 'toLocaleString']);
function calleeName(node) {
return node.type === 'Identifier' ? node.name : null;
}
function isTextReturningCall(node) {
const { callee } = node;
if (TEXT_RETURNING_CALLEES.has(calleeName(callee))) {
return node.arguments.length > 0;
}
if (callee.type === 'MemberExpression' && !callee.computed) {
return STRINGIFY_METHODS.has(calleeName(callee.property));
}
return STRINGIFY_METHODS.has(calleeName(callee));
}
/**
* True when the node renders no visible text. An empty or whitespace-only value
* produces no DOM text node, so Google Translate has nothing to wrap and React
* nothing to lose.
*/
function isBlankText(node) {
if (node.type === 'Literal' || node.type === 'JSXText') {
return typeof node.value === 'string' && node.value.trim() === '';
}
if (node.type === 'TemplateLiteral') {
return (
node.expressions.length === 0 &&
node.quasis.every((quasi) => (quasi.value.cooked ?? '').trim() === '')
);
}
return false;
}
function isConditionallyRendered(node) {
const parent = node.parent;
return (
parent?.type === 'ConditionalExpression' ||
parent?.type === 'LogicalExpression'
);
}
function isRenderedConditional(node) {
return (
node.type === 'JSXExpressionContainer' &&
(node.expression?.type === 'ConditionalExpression' ||
node.expression?.type === 'LogicalExpression')
);
}
/** Children that produce output, i.e. everything but formatting whitespace. */
function renderedChildren(node) {
const children = node?.children;
if (!children) {
return null;
}
return children.filter((child) => !isBlankText(child));
}
/** True when `node` is a JSX child rendered alongside at least one other child. */
function hasSiblings(node) {
if (!(node?.parent?.children?.length > 1)) {
return false;
}
return renderedChildren(node.parent).some((child) => child !== node);
}
function isPrecededByConditional(node) {
const children = renderedChildren(node?.parent);
if (!children) {
return false;
}
return children.some(
(child) => child.start < node.start && isRenderedConditional(child),
);
}
/** Walk out of nested conditionals so nested branches report against the outer container. */
function getOutermostConditional(node) {
let current = node;
while (isConditionallyRendered(current)) {
current = current.parent;
}
return current;
}
/** True when `node` is a conditional branch rendered directly beside other JSX children. */
function isProblematicConditional(node) {
if (!isConditionallyRendered(node)) {
return false;
}
const container = getOutermostConditional(node);
return (
container.parent?.type === 'JSXExpressionContainer' &&
container.parent.parent?.type === 'JSXElement' &&
hasSiblings(container.parent)
);
}
/** True when `node` renders after a sibling conditional, i.e. the DOM order Translate breaks. */
function followsConditionalSibling(node) {
return (
node.parent?.parent?.type === 'JSXElement' &&
hasSiblings(node.parent) &&
isPrecededByConditional(node.parent)
);
}
/**
* `A && B` and the test of a ternary are conditions, not rendered output.
*/
function isCondition(node) {
let current = node;
while (current.parent?.type === 'LogicalExpression') {
if (current.parent.left === current) {
return true;
}
current = current.parent;
}
if (current.parent?.type === 'ConditionalExpression') {
return current.parent.test === current;
}
return false;
}
function isConditionOperand(node) {
if (node.parent?.type === 'BinaryExpression') {
return isCondition(node.parent);
}
return isCondition(node);
}
// A string may only be inlined as JSX text when it needs no escaping and no
// whitespace of its own: JSX collapses leading and trailing whitespace, and
// these characters would either close the element or start an entity.
const NEEDS_BRACES = /['"{}<>&\r\n]/;
// `display: contents`, declared in src/styles.scss. React owns the element so
// Translate's `<font>` swap is absorbed, while the box tree stays as it was and
// a flex or grid parent still sees one contiguous text run.
const OPEN = '<span className="translate-safe">';
const CLOSE = '</span>';
/** Wraps the reported expression so React owns an element Translate cannot replace. */
function wrapExpression(fixer, sourceCode, node) {
// A call reported on its own already sits in a container. Replacing the
// container yields `<span …>{expr}</span>` rather than `{<span …>{expr}</span>}`.
const target =
node.parent?.type === 'JSXExpressionContainer' ? node.parent : node;
if (
node.type === 'Literal' &&
typeof node.value === 'string' &&
!NEEDS_BRACES.test(node.value) &&
node.value.trim() === node.value
) {
return fixer.replaceText(target, `${OPEN}${node.value}${CLOSE}`);
}
return fixer.replaceText(
target,
`${OPEN}{${sourceCode.getText(node)}}${CLOSE}`,
);
}
/**
* Wraps static JSX text. Only the visible run is wrapped: the node also spans
* the formatting whitespace around it, which has to stay outside the element.
*/
function wrapJsxText(fixer, node) {
const raw = node.value;
const leading = raw.length - raw.trimStart().length;
const trailing = raw.length - raw.trimEnd().length;
return fixer.replaceTextRange(
[node.start + leading, node.end - trailing],
`${OPEN}${raw.trim()}${CLOSE}`,
);
}
export default {
meta: {
type: 'problem',
docs: {
description:
'Conditionally rendered text nodes should be wrapped in an element (for example a `<span>`), otherwise Google Translate can cause a browser error.',
url: 'https://github.com/getcouped/eslint-plugin-react-google-translate#eslint-plugin-react-google-translate',
},
schema: [],
// Wrapping adds a DOM element, which can turn into a flex/grid item or
// break `> *` and `:nth-child` selectors, so it is offered as a suggestion
// (`--fix-suggestions`) rather than applied by a bare `--fix`.
hasSuggestions: true,
messages: {
'conditional-text-node':
'Conditionally rendered text nodes with siblings, rendered as a direct child of a JSX element, must be wrapped so Google Translate cannot break React\'s DOM: `<span className="translate-safe">{value}</span>`. Translate replaces the bare text node with a `<font>` element and React then throws on `removeChild`. This also applies to values returned from functions, so `getString()` becomes `<span className="translate-safe">{getString()}</span>`.',
'text-node-preceded-by-conditional':
'Text nodes which are preceded by a conditional expression, rendered as a direct child of a JSX element, must be wrapped so Google Translate cannot break React\'s DOM: `<span className="translate-safe">text</span>`. Translate replaces the bare text node with a `<font>` element and React then throws on `removeChild`.',
},
},
createOnce(context) {
const suggestWrap = (build) => [{ desc: 'Wrap in a <span>', fix: build }];
const wrap = (fixer, node) => wrapExpression(fixer, context.sourceCode, node);
const reportConditional = (node) => {
context.report({
node,
messageId: 'conditional-text-node',
suggest: suggestWrap((fixer) => wrap(fixer, node)),
});
};
const reportPreceded = (node, build) => {
context.report({
node,
messageId: 'text-node-preceded-by-conditional',
suggest: suggestWrap(build),
});
};
return {
// String and numeric branches: `{flag ? 'yes' : 'no'}`
Literal(node) {
if (node.value === null || typeof node.value === 'boolean') {
return;
}
if (isBlankText(node)) {
return;
}
if (isProblematicConditional(node)) {
reportConditional(node);
}
},
TemplateLiteral(node) {
if (isBlankText(node)) {
return;
}
if (isProblematicConditional(node)) {
reportConditional(node);
}
},
// Static text rendered after a conditional: `{flag && <b/>}trailing`
JSXText(node) {
if (isBlankText(node)) {
return;
}
if (hasSiblings(node) && isPrecededByConditional(node)) {
reportPreceded(node, (fixer) => wrapJsxText(fixer, node));
}
},
CallExpression(node) {
if (isCondition(node) || !isTextReturningCall(node)) {
return;
}
if (isProblematicConditional(node)) {
reportConditional(node);
}
if (followsConditionalSibling(node)) {
reportPreceded(node, (fixer) => wrap(fixer, node));
}
},
// Values read off an object: `{flag ? user.name : 'anonymous'}`
MemberExpression(node) {
if (isConditionOperand(node)) {
return;
}
if (isProblematicConditional(node)) {
reportConditional(node);
}
},
// Optional chaining wraps the member expression: `{flag ? a?.b?.c : 'x'}`
ChainExpression(node) {
if (isConditionOperand(node)) {
return;
}
if (isProblematicConditional(node)) {
reportConditional(node);
}
},
};
},
};

View File

@@ -0,0 +1,115 @@
/**
* Rule: no-return-text-nodes
*
* React components should not return a bare text node. Google Translate keeps
* displaying the stale translated text after a state change and nothing throws,
* which makes the bug very hard to track down. Numbers count too: JSX renders
* them as text.
*
* Adapted from https://github.com/getcouped/eslint-plugin-react-google-translate
* (v1.0.4). Upstream walks the function body statement by statement; this
* version visits `ReturnStatement` directly and walks up to the enclosing
* function, which covers the same constructs without enumerating them.
*/
const FUNCTION_TYPES = new Set([
'FunctionDeclaration',
'FunctionExpression',
'ArrowFunctionExpression',
]);
function isTextNode(node) {
if (!node) {
return false;
}
if (node.type === 'TemplateLiteral') {
return true;
}
return (
node.type === 'Literal' &&
(typeof node.value === 'string' || typeof node.value === 'number')
);
}
function getEnclosingFunction(node) {
let current = node.parent;
while (current) {
if (FUNCTION_TYPES.has(current.type)) {
return current;
}
current = current.parent;
}
return null;
}
function isComponentName(name) {
return (
typeof name === 'string' && name !== '' && name[0] === name[0].toUpperCase()
);
}
// The suggestion introduces JSX, which only parses in a JSX-enabled file.
function allowsJsx(filename) {
return filename.endsWith('.tsx') || filename.endsWith('.jsx');
}
export default {
meta: {
type: 'problem',
docs: {
description:
'React components should avoid returning text nodes directly (or numerical values which will be rendered as text). When a React component returns values other than JSX / null, Google Translate can continue to display stale values after state changes, without any error being thrown. Since this is very hard to debug it is better to avoid it altogether.',
url: 'https://github.com/getcouped/eslint-plugin-react-google-translate#eslint-plugin-react-google-translate',
},
schema: [],
// Wrapping changes what the component renders, so it is offered as a
// suggestion (`--fix-suggestions`) rather than applied by a bare `--fix`.
hasSuggestions: true,
messages: {
'return-value-is-text-node':
'React components should avoid returning text nodes directly (or numerical values which will be rendered as text). When a React component returns values other than JSX / null, Google Translate can continue to display stale values after state changes, without any error being thrown. Since this is very hard to debug it is better to avoid it altogether.',
},
},
createOnce(context) {
const buildSuggestion = (node) => {
if (!allowsJsx(context.filename)) {
return undefined;
}
return [
{
desc: 'Wrap in a <span>',
fix: (fixer) =>
fixer.replaceText(
node.argument,
`<span className="translate-safe">{${context.sourceCode.getText(node.argument)}}</span>`,
),
},
];
};
return {
ReturnStatement(node) {
if (!isTextNode(node.argument)) {
return;
}
// Only named function declarations are recognised as components, so a
// text return from a nested helper or a class method is left alone.
const fn = getEnclosingFunction(node);
if (fn?.type !== 'FunctionDeclaration') {
return;
}
if (!isComponentName(fn.id?.name)) {
return;
}
context.report({
node,
messageId: 'return-value-is-text-node',
suggest: buildSuggestion(node),
});
},
};
},
};

View File

@@ -13,6 +13,8 @@ import noAntdComponents from './rules/no-antd-components.mjs';
import noSignozhqUiBarrel from './rules/no-signozhq-ui-barrel.mjs';
import noCssModuleBracketAccess from './rules/no-css-module-bracket-access.mjs';
import noDashboardFetchOutsideRoot from './rules/no-dashboard-fetch-outside-root.mjs';
import noConditionalTextNodesWithSiblings from './rules/no-conditional-text-nodes-with-siblings.mjs';
import noReturnTextNodes from './rules/no-return-text-nodes.mjs';
export default {
meta: {
@@ -27,5 +29,7 @@ export default {
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
'no-css-module-bracket-access': noCssModuleBracketAccess,
'no-dashboard-fetch-outside-root': noDashboardFetchOutsideRoot,
'no-conditional-text-nodes-with-siblings': noConditionalTextNodesWithSiblings,
'no-return-text-nodes': noReturnTextNodes,
},
};

View File

@@ -294,6 +294,9 @@ importers:
timestamp-nano:
specifier: ^1.0.0
version: 1.0.1
translation-resilience:
specifier: ^0.2.0
version: 0.2.0
typescript:
specifier: 5.9.3
version: 5.9.3
@@ -8475,6 +8478,9 @@ packages:
resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==}
engines: {node: '>=12'}
translation-resilience@0.2.0:
resolution: {integrity: sha512-IxTjhpHGp1SJxVEEPBu/YbBaHnymMRJdYxUY1i5qtYADuz9b8fdWhZSE/EeRS2aVIiuQjRqtYDk69ruS/3fzTg==}
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@@ -18232,6 +18238,8 @@ snapshots:
dependencies:
punycode: 2.3.1
translation-resilience@0.2.0: {}
trim-lines@3.0.1: {}
trough@2.1.0: {}

View File

@@ -0,0 +1,280 @@
import {
completionStatus,
currentCompletions,
startCompletion,
} from '@codemirror/autocomplete';
import { EditorView } from '@uiw/react-codemirror';
import { initialQueriesMap } from 'constants/queryBuilder';
import * as recentQueriesStore from 'lib/recentQueries/recentQueriesStore';
import { fireEvent, render, userEvent, waitFor } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import { RECENTS_DISPLAY_CAP, RECENTS_SECTION } from '../QuerySearch/constants';
import QuerySearch from '../QuerySearch/QuerySearch';
import { mockCodeMirrorDomApis } from './codemirrorDomMocks';
const CM_ROOT_SELECTOR = '.cm-editor';
const CM_EDITOR_SELECTOR = '.cm-editor .cm-content';
const TOOLTIP_SELECTOR = '.cm-tooltip-autocomplete';
const COMPLETION_LABEL_SELECTOR = '.cm-completionLabel';
const DELETE_BUTTON_SELECTOR = '.cm-recent-delete';
const FRONTEND_FILTER = "service.name = 'frontend'";
const STATUS_CODE_FILTER = "http.status_code = '500'";
const TRACES_FILTER = "name = 'HTTP GET'";
beforeAll(() => {
mockCodeMirrorDomApis();
});
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): { dashboardData: undefined } => ({
dashboardData: undefined,
}),
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn().mockResolvedValue({
data: { data: { keys: {} } },
}),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn().mockResolvedValue({
data: { data: { values: { stringValues: [], numberValues: [] } } },
}),
}));
function renderLogsSearch(onChange: (value: string) => void = jest.fn()): void {
render(
<QuerySearch
onChange={onChange}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
/>,
);
}
function saveLogsRecent(expression: string): void {
recentQueriesStore.save({ signal: 'logs', filter: { expression } });
}
function getEditorView(): EditorView | null {
const root = document.querySelector<HTMLElement>(CM_ROOT_SELECTOR);
return root ? EditorView.findFromDOM(root) : null;
}
function getDocText(): string {
return getEditorView()?.state.doc.toString() ?? '';
}
function isCompletionOpen(): boolean {
const view = getEditorView();
return !!view && completionStatus(view.state) === 'active';
}
// Reads recents from completion state, not the tooltip: the tooltip is a later render
// pass over this same state, so going to the source drops a layer of timing.
function getRecentLabels(): string[] {
const view = getEditorView();
if (!view) {
return [];
}
return currentCompletions(view.state)
.filter((completion) => completion.section === RECENTS_SECTION)
.map((completion) => completion.label);
}
async function renderAndFocus(
onChange: (value: string) => void = jest.fn(),
): Promise<HTMLElement> {
renderLogsSearch(onChange);
const editor = await waitFor(
() => {
const element = document.querySelector(CM_EDITOR_SELECTOR);
expect(element).toBeInTheDocument();
return element as HTMLElement;
},
{ timeout: 2000 },
);
await userEvent.click(editor);
return editor;
}
function openRecents(): Promise<void> {
return waitFor(
() => {
const view = getEditorView();
if (view && !isCompletionOpen()) {
startCompletion(view);
}
expect(getRecentLabels().length).toBeGreaterThan(0);
},
{ timeout: 3000 },
);
}
describe('QuerySearch recent searches', () => {
beforeEach(() => {
recentQueriesStore.useRecentQueriesStore.setState({ buckets: {} });
localStorage.clear();
});
it('shows a saved recent query under "Recent searches" on focus', async () => {
saveLogsRecent(FRONTEND_FILTER);
await renderAndFocus();
await openRecents();
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual([FRONTEND_FILTER]);
},
{ timeout: 3000 },
);
const view = getEditorView() as EditorView;
const [recent] = currentCompletions(view.state);
expect(recent.section).toBe(RECENTS_SECTION);
});
it('filters recents by substring as the user types', async () => {
saveLogsRecent(FRONTEND_FILTER);
saveLogsRecent(STATUS_CODE_FILTER);
const editor = await renderAndFocus();
await openRecents();
await userEvent.type(editor, 'status_code');
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual([STATUS_CODE_FILTER]);
},
{ timeout: 3000 },
);
});
it('does not surface recents saved under a different signal', async () => {
recentQueriesStore.save({
signal: 'traces',
filter: { expression: TRACES_FILTER },
});
saveLogsRecent(FRONTEND_FILTER);
await renderAndFocus();
await openRecents();
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual([FRONTEND_FILTER]);
},
{ timeout: 3000 },
);
});
it('excludes a recent that exactly matches the current editor text', async () => {
const supersetFilter = `${FRONTEND_FILTER} AND ${STATUS_CODE_FILTER}`;
saveLogsRecent(FRONTEND_FILTER);
saveLogsRecent(supersetFilter);
const editor = await renderAndFocus();
await openRecents();
await userEvent.type(editor, FRONTEND_FILTER);
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual([supersetFilter]);
},
{ timeout: 3000 },
);
});
it('caps the dropdown at RECENTS_DISPLAY_CAP entries, newest first', async () => {
const filters = Array.from(
{ length: RECENTS_DISPLAY_CAP + 1 },
(_, index) => `attribute_${index + 1} = 'v'`,
);
filters.forEach((filter) => saveLogsRecent(filter));
const expectedLabels = [...filters].reverse().slice(0, RECENTS_DISPLAY_CAP);
await renderAndFocus();
await openRecents();
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual(expectedLabels);
},
{ timeout: 3000 },
);
});
it('applies the full expression to the editor when a recent is clicked', async () => {
saveLogsRecent(FRONTEND_FILTER);
const onChange = jest.fn();
await renderAndFocus(onChange);
await openRecents();
const option = await waitFor(
() => {
const node = Array.from(
document.querySelectorAll<HTMLElement>(COMPLETION_LABEL_SELECTOR),
).find((element) => element.textContent === FRONTEND_FILTER);
expect(node).toBeDefined();
return node as HTMLElement;
},
{ timeout: 3000 },
);
await userEvent.click(option);
await waitFor(
() => {
expect(getDocText()).toBe(FRONTEND_FILTER);
},
{ timeout: 2000 },
);
expect(onChange).toHaveBeenCalledWith(FRONTEND_FILTER);
await waitFor(
() => {
expect(document.querySelector(TOOLTIP_SELECTOR)).not.toBeInTheDocument();
},
{ timeout: 2000 },
);
});
it('removes a recent from the dropdown and the store when delete is clicked', async () => {
saveLogsRecent(FRONTEND_FILTER);
await renderAndFocus();
await openRecents();
const deleteButton = await waitFor(
() => {
const button = document.querySelector(DELETE_BUTTON_SELECTOR);
expect(button).toBeInTheDocument();
return button as HTMLElement;
},
{ timeout: 3000 },
);
// fireEvent: the button preventDefaults pointerdown, which makes userEvent.click drop the mouse chain.
fireEvent.click(deleteButton);
await waitFor(
() => {
expect(recentQueriesStore.list('logs')).toHaveLength(0);
expect(getRecentLabels()).not.toContain(FRONTEND_FILTER);
},
{ timeout: 2000 },
);
expect(getDocText()).toBe('');
});
});

View File

@@ -1617,13 +1617,13 @@ export const getNamespaceMetricsQueryPayload = (
reduceTo: ReduceOperators.LAST,
spaceAggregation: 'max',
stepInterval: 60,
timeAggregation: 'avg',
timeAggregation: 'latest',
},
],
queryFormulas: [
{
disabled: false,
expression: 'A/B',
expression: '(B/A) * 100',
legend: 'util %',
queryName: 'F1',
},

View File

@@ -1521,9 +1521,9 @@ const onboardingConfigWithLinks = [
},
{
dataSource: 'nginx-tracing',
label: 'Nginx - Tracing',
label: 'Nginx - OpenTelemetry',
imgUrl: nginxUrl,
tags: ['apm/traces'],
tags: ['apm/traces', 'logs', 'metrics'],
module: 'apm',
relatedSearchKeywords: [
'apm',
@@ -1626,7 +1626,7 @@ const onboardingConfigWithLinks = [
dataSource: 'cloudflare-workers',
label: 'Cloudflare Workers',
imgUrl: cloudflareUrl,
tags: ['apm/traces'],
tags: ['apm/traces', 'logs'],
module: 'apm',
relatedSearchKeywords: [
'cloudflare',
@@ -5346,13 +5346,17 @@ const onboardingConfigWithLinks = [
dataSource: 'temporal',
label: 'Temporal',
imgUrl: temporalUrl,
tags: ['apm/traces'],
tags: ['apm/traces', 'logs', 'metrics'],
module: 'apm',
relatedSearchKeywords: [
'apm',
'application performance monitoring',
'integrations',
'logs',
'metrics',
'temporal',
'temporal logs',
'temporal metrics',
'temporal traces',
'traces',
'tracing',
@@ -5478,7 +5482,7 @@ const onboardingConfigWithLinks = [
dataSource: 'dbos',
label: 'DBOS',
imgUrl: dbosUrl,
tags: ['apm/traces'],
tags: ['apm/traces', 'logs'],
module: 'apm',
relatedSearchKeywords: [
'database oriented',
@@ -6622,7 +6626,7 @@ const onboardingConfigWithLinks = [
dataSource: 'opentelemetry-ebpf',
label: 'OpenTelemetry eBPF (OBI)',
imgUrl: opentelemetryUrl,
tags: ['apm/traces'],
tags: ['apm/traces', 'metrics'],
module: 'apm',
relatedSearchKeywords: [
'auto instrumentation',

View File

@@ -13,11 +13,13 @@ import { AppProvider } from 'providers/App/App';
import TimezoneProvider from 'providers/Timezone';
import store from 'store';
import APIError from 'types/api/error';
import { installTranslationResilience } from 'translation-resilience';
import './ReactI18';
import 'styles.scss';
installTranslationResilience();
configureOverlayScrollbars();
const queryClient = new QueryClient({

View File

@@ -42,7 +42,7 @@ function SpanPercentilePanel({
selectedTimeRange,
setSelectedTimeRange,
showResourceAttributesSelector,
setShowResourceAttributesSelector,
toggleResourceAttributesSelector,
resourceAttributesSearchQuery,
setResourceAttributesSearchQuery,
spanResourceAttributes,
@@ -72,9 +72,7 @@ function SpanPercentilePanel({
variant="link"
color="secondary"
size="icon"
onClick={(): void =>
setShowResourceAttributesSelector(!showResourceAttributesSelector)
}
onClick={toggleResourceAttributesSelector}
prefix={
showResourceAttributesSelector ? <Check size={16} /> : <Plus size={16} />
}

View File

@@ -8,6 +8,11 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { USER_PREFERENCES } from 'constants/userPreferences';
import dayjs from 'dayjs';
import useClickOutside from 'hooks/useClickOutside';
import {
TraceDetailEventKeys,
TraceDetailEvents,
} from 'pages/TraceDetailsV3/events';
import { useTraceDetailLogEvent } from 'pages/TraceDetailsV3/hooks/useTraceDetailLogEvent';
import { SpanV3 } from 'types/api/trace/getTraceV3';
export interface IResourceAttribute {
@@ -37,7 +42,7 @@ export interface UseSpanPercentileReturn {
selectedTimeRange: number;
setSelectedTimeRange: (range: number) => void;
showResourceAttributesSelector: boolean;
setShowResourceAttributesSelector: (show: boolean) => void;
toggleResourceAttributesSelector: () => void;
resourceAttributesSearchQuery: string;
setResourceAttributesSearchQuery: (query: string) => void;
spanResourceAttributes: IResourceAttribute[];
@@ -76,6 +81,8 @@ function useSpanPercentile(selectedSpan: SpanV3): UseSpanPercentileReturn {
const resourceAttributesSelectorRef = useRef<HTMLDivElement | null>(null);
const logTraceEvent = useTraceDetailLogEvent('v3', selectedSpan.trace_id);
useClickOutside({
ref: resourceAttributesSelectorRef,
onClickOutside: () => {
@@ -257,6 +264,12 @@ function useSpanPercentile(selectedSpan: SpanV3): UseSpanPercentileReturn {
const handleResourceAttributeChange = useCallback(
(key: string, value: string, isSelected: boolean): void => {
logTraceEvent(TraceDetailEvents.SpanPercentileAttributeChanged, {
[TraceDetailEventKeys.SpanId]: selectedSpan.span_id,
[TraceDetailEventKeys.ResourceAttributeKey]: key,
[TraceDetailEventKeys.Selected]: isSelected,
});
updateSpanResourceAttributes((prev) =>
prev.map((attr) => (attr.key === key ? { ...attr, isSelected } : attr)),
);
@@ -271,7 +284,7 @@ function useSpanPercentile(selectedSpan: SpanV3): UseSpanPercentileReturn {
setShouldFetchData(true);
setShouldUpdateUserPreference(true);
},
[selectedResourceAttributes],
[selectedResourceAttributes, logTraceEvent, selectedSpan.span_id],
);
useEffect(() => {
@@ -293,12 +306,37 @@ function useSpanPercentile(selectedSpan: SpanV3): UseSpanPercentileReturn {
'ms',
);
const toggleOpen = useCallback(() => setIsOpen((prev) => !prev), []);
const toggleOpen = useCallback(() => {
const nextOpen = !isOpen;
setIsOpen(nextOpen);
logTraceEvent(TraceDetailEvents.SpanPercentileToggled, {
[TraceDetailEventKeys.SpanId]: selectedSpan.span_id,
[TraceDetailEventKeys.Open]: nextOpen,
[TraceDetailEventKeys.PercentileValue]: percentileValue,
});
}, [isOpen, logTraceEvent, selectedSpan.span_id, percentileValue]);
const handleTimeRangeChange = useCallback((range: number): void => {
setShouldFetchData(true);
setSelectedTimeRange(range);
}, []);
const toggleResourceAttributesSelector = useCallback(() => {
const nextOpen = !showResourceAttributesSelector;
setShowResourceAttributesSelector(nextOpen);
logTraceEvent(TraceDetailEvents.SpanPercentileAttributesSelectorToggled, {
[TraceDetailEventKeys.SpanId]: selectedSpan.span_id,
[TraceDetailEventKeys.Open]: nextOpen,
});
}, [showResourceAttributesSelector, logTraceEvent, selectedSpan.span_id]);
const handleTimeRangeChange = useCallback(
(range: number): void => {
logTraceEvent(TraceDetailEvents.SpanPercentileTimeRangeChanged, {
[TraceDetailEventKeys.SpanId]: selectedSpan.span_id,
[TraceDetailEventKeys.From]: selectedTimeRange,
[TraceDetailEventKeys.To]: range,
});
setShouldFetchData(true);
setSelectedTimeRange(range);
},
[logTraceEvent, selectedSpan.span_id, selectedTimeRange],
);
return {
isOpen,
@@ -312,7 +350,7 @@ function useSpanPercentile(selectedSpan: SpanV3): UseSpanPercentileReturn {
selectedTimeRange,
setSelectedTimeRange: handleTimeRangeChange,
showResourceAttributesSelector,
setShowResourceAttributesSelector,
toggleResourceAttributesSelector,
resourceAttributesSearchQuery,
setResourceAttributesSearchQuery,
spanResourceAttributes,

View File

@@ -8,6 +8,10 @@ export enum TraceDetailEvents {
SpanPanelTabChanged = 'Trace Detail: Span panel tab changed',
DownloadTriggered = 'Trace Detail: Download triggered',
DownloadCancelled = 'Trace Detail: Download cancelled',
SpanPercentileToggled = 'Trace Detail: Span percentile toggled',
SpanPercentileTimeRangeChanged = 'Trace Detail: Span percentile time range changed',
SpanPercentileAttributesSelectorToggled = 'Trace Detail: Span percentile attributes selector toggled',
SpanPercentileAttributeChanged = 'Trace Detail: Span percentile attribute changed',
}
export enum TraceDetailEventKeys {
@@ -36,6 +40,10 @@ export enum TraceDetailEventKeys {
SpanId = 'spanId',
// Download triggered (reuses TotalSpansCount for trace size)
Format = 'format',
// Span percentile (reuses Open, SpanId, From, To)
PercentileValue = 'percentileValue',
ResourceAttributeKey = 'resourceAttributeKey',
Selected = 'selected',
}
export type TraceDetailView = 'v2' | 'v3';

View File

@@ -183,7 +183,7 @@
font-family: 'Space Mono', monospace !important;
border: none;
height: 36px;
min-height: 36px;
.ant-select-selection-search-input {
min-width: max-content !important;
max-width: 100% !important;
@@ -191,7 +191,7 @@
}
.ant-select-selector {
height: 36px;
min-height: 36px;
border-color: var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
border-radius: 0;

View File

@@ -30,6 +30,10 @@ body {
box-sizing: border-box;
}
.translate-safe {
display: contents;
}
// Respect user's reduced motion preference
@media (prefers-reduced-motion: reduce) {
* {

2
go.mod
View File

@@ -4,7 +4,7 @@ go 1.25.7
require (
dario.cat/mergo v1.0.2
github.com/AfterShip/clickhouse-sql-parser v0.5.5
github.com/AfterShip/clickhouse-sql-parser v0.5.6
github.com/ClickHouse/clickhouse-go/v2 v2.44.0
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/SigNoz/clickhouse-go-mock v0.14.0

4
go.sum
View File

@@ -66,8 +66,8 @@ dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/AfterShip/clickhouse-sql-parser v0.5.5 h1:LCA23yAA4GgF73PoYXb67yzCdC4sXsj4geQz1Oij3U8=
github.com/AfterShip/clickhouse-sql-parser v0.5.5/go.mod h1:Qi3qvPTfZb/aFwI5V4WFOahgjsLJa4MzVijIAfwOhDw=
github.com/AfterShip/clickhouse-sql-parser v0.5.6 h1:njgRLxQz/pE16ZO1MSjWadIzabwqsjDMMX8RR5Dbv7Y=
github.com/AfterShip/clickhouse-sql-parser v0.5.6/go.mod h1:Qi3qvPTfZb/aFwI5V4WFOahgjsLJa4MzVijIAfwOhDw=
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=

View File

@@ -1,9 +0,0 @@
package prometheus
import "net/http"
type Handler interface {
Query(http.ResponseWriter, *http.Request)
QueryRange(http.ResponseWriter, *http.Request)
}

View File

@@ -1,259 +0,0 @@
// Package promapi serves the Prometheus HTTP query API over a
// prometheus.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.
package promapi
import (
"context"
"encoding/json"
"log/slog"
"math"
"net/http"
"strconv"
"time"
promModel "github.com/prometheus/common/model"
"github.com/prometheus/prometheus/promql"
"github.com/prometheus/prometheus/promql/parser"
"github.com/prometheus/prometheus/util/stats"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/prometheus"
)
type handler struct {
logger *slog.Logger
prom prometheus.Prometheus
}
func NewHandler(logger *slog.Logger, prom prometheus.Prometheus) prometheus.Handler {
return &handler{logger: logger, prom: prom}
}
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"`
}
// 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.(prometheus.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
}
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))
}
}
// 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)
}

View File

@@ -4135,10 +4135,6 @@ func (r *ClickHouseReader) GetTimeSeriesResultV3(ctx context.Context, query stri
return readRowsForTimeSeriesResult(rows, vars, columnNames, countOfNumberCols)
}
func isJSONColumn(columnType driver.ColumnType) bool {
return strings.HasPrefix(strings.ToUpper(columnType.DatabaseTypeName()), "JSON")
}
// GetListResultV3 runs the query and returns list of rows
func (r *ClickHouseReader) GetListResultV3(ctx context.Context, query string) ([]*v3.Row, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
@@ -4163,12 +4159,6 @@ func (r *ClickHouseReader) GetListResultV3(ctx context.Context, query string) ([
for rows.Next() {
var vars = make([]interface{}, len(columnTypes))
for i := range columnTypes {
if isJSONColumn(columnTypes[i]) {
// the driver fails to decode JSON into native Go values, so it is read as raw bytes
var raw []byte
vars[i] = &raw
continue
}
vars[i] = reflect.New(columnTypes[i].ScanType()).Interface()
}
if err := rows.Scan(vars...); err != nil {
@@ -4177,17 +4167,7 @@ func (r *ClickHouseReader) GetListResultV3(ctx context.Context, query string) ([
row := map[string]interface{}{}
var t time.Time
for idx, v := range vars {
if isJSONColumn(columnTypes[idx]) {
raw, ok := v.(*[]byte)
if !ok {
continue
}
var value map[string]interface{}
if err := json.Unmarshal(*raw, &value); err != nil {
return nil, errors.New(err.Error())
}
row[columnNames[idx]] = value
} else if columnNames[idx] == "timestamp" {
if columnNames[idx] == "timestamp" {
switch v := v.(type) {
case *uint64:
t = time.Unix(0, int64(*v))

View File

@@ -484,9 +484,6 @@ 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("/prometheus/api/v1/query_range", am.ViewAccess(aH.Signoz.Handlers.PrometheusHandler.QueryRange)).Methods(http.MethodGet, http.MethodPost)
router.HandleFunc("/prometheus/api/v1/query", am.ViewAccess(aH.Signoz.Handlers.PrometheusHandler.Query)).Methods(http.MethodGet, http.MethodPost)
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)

View File

@@ -205,8 +205,9 @@ const (
"scope_string "
LogsSQLSelectV2 = logsSQLSelectV2Head + "body, " + logsSQLSelectV2Tail
// Orgs on JSON bodies keep the body in body_v2 and have the body column written empty.
// Selected as JSON so the response carries the same body object v5 returns.
LogsSQLSelectV2WithBodyJSON = logsSQLSelectV2Head + "body_v2 as body, " + logsSQLSelectV2Tail
// Stringified because filters emit a bare `body`, which ClickHouse resolves to this alias:
// as JSON it fails every string comparison, as String it matches against the body text.
LogsSQLSelectV2WithBodyJSON = logsSQLSelectV2Head + "toString(body_v2) as body, " + logsSQLSelectV2Tail
TracesExplorerViewSQLSelectWithSubQuery = "(SELECT traceID, durationNano, " +
"serviceName, name FROM %s.%s WHERE parentSpanID = '' AND %s ORDER BY durationNano DESC LIMIT 1 BY traceID"
TracesExplorerViewSQLSelectBeforeSubQuery = "SELECT subQuery.serviceName as `subQuery.serviceName`, subQuery.name as `subQuery.name`, count() AS " +

View File

@@ -25,6 +25,10 @@ func TestErrIfStatementIsNotValid_Pass(t *testing.T) {
{"GlobalNotIn", "SELECT a FROM t WHERE a GLOBAL NOT IN (SELECT b FROM t2)"},
{"Union", "SELECT * FROM t UNION ALL SELECT * FROM t2"},
{"Intersect", "SELECT * FROM t INTERSECT SELECT * FROM t2"},
// A parenthesised left operand of a set operator. https://github.com/AfterShip/clickhouse-sql-parser/pull/312
{"ParenthesisedUnionLeftOperand", "SELECT a FROM ((SELECT 1 AS a) UNION ALL (SELECT 2 AS a))"},
{"ParenthesisedExceptLeftOperand", "SELECT a FROM ((SELECT 1 AS a) EXCEPT (SELECT 2 AS a))"},
{"ParenthesisedUnionLeftOperandAtStatementLevel", "(SELECT 1 AS a) UNION ALL (SELECT 2 AS a)"},
{"WindowFunction", "SELECT sum(v) OVER (PARTITION BY a ORDER BY t) FROM t"},
{"UnrelatedSetting", "SELECT * FROM t SETTINGS max_threads = 4"},
{"TerminatedBlockComment", "SELECT /* keep me */ count() FROM t"},
@@ -183,12 +187,10 @@ func TestErrIfStatementIsNotValid_ShouldPassButFails(t *testing.T) {
query string
expectedCode errors.Code
}{
// The left operand commits the parser to a subquery, leaving the operator nowhere to bind. Parenthesising only the right operand is fine.
{"ParenthesisedUnionLeftOperand", "SELECT a FROM ((SELECT 1 AS a) UNION ALL (SELECT 2 AS a))", CodeClickHouseSQLUnparseable},
{"ParenthesisedExceptLeftOperand", "SELECT a FROM ((SELECT 1 AS a) EXCEPT (SELECT 2 AS a))", CodeClickHouseSQLUnparseable},
{"ParenthesisedUnionLeftOperandAtStatementLevel", "(SELECT 1 AS a) UNION ALL (SELECT 2 AS a)", CodeClickHouseSQLUnparseable},
// The one keyword PR 305 left behind, because ON also opens a join condition.
{"UnquotedOnAsColumnName", "SELECT on + 1 FROM t", CodeClickHouseSQLUnparseable},
// ClickHouse accepts NULLS FIRST|LAST as an ORDER BY modifier; the parser's grammar has no rule for it.
{"OrderByNullsLast", "SELECT x FROM t ORDER BY x DESC NULLS LAST", CodeClickHouseSQLUnparseable},
}
for _, testCase := range testCases {

View File

@@ -48,8 +48,6 @@ 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/prometheus/promapi"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/ruler/signozruler"
@@ -83,7 +81,6 @@ 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,7 +101,6 @@ func NewHandlers(
zeusService zeus.Zeus,
registryHandler factory.Handler,
alertmanagerService alertmanager.Alertmanager,
prometheusService prometheus.Prometheus,
rulerService ruler.Ruler,
statsAggregator statsreporter.Aggregator,
) Handlers {
@@ -133,7 +129,6 @@ func NewHandlers(
CloudIntegrationHandler: implcloudintegration.NewHandler(modules.CloudIntegration),
SpanMapperHandler: implspanmapper.NewHandler(modules.SpanMapper),
AlertmanagerHandler: signozalertmanager.NewHandler(alertmanagerService),
PrometheusHandler: promapi.NewHandler(providerSettings.Logger, prometheusService),
TraceDetail: impltracedetail.NewHandler(modules.TraceDetail),
RulerHandler: signozruler.NewHandler(rulerService),
LLMPricingRuleHandler: impllmpricingrule.NewHandler(modules.LLMPricingRule),

View File

@@ -63,7 +63,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, nil)
handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil, registryHandler, alertmanager, nil, nil)
reflectVal := reflect.ValueOf(handlers)
for i := 0; i < reflectVal.NumField(); i++ {
f := reflectVal.Field(i)

View File

@@ -239,6 +239,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddSavedViewTuplesFactory(sqlstore),
sqlmigration.NewFixSavedViewSelectedFieldsFactory(sqlstore),
sqlmigration.NewBackfillSavedViewRequestTypeFactory(sqlstore),
sqlmigration.NewFixSavedViewSelectFieldsFactory(sqlstore),
)
}

View File

@@ -617,7 +617,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, prometheus, rulerInstance, statsAggregator)
handlers := NewHandlers(modules, providerSettings, analytics, querierHandler, licensing, global, flagger, gateway, telemetryMetadataStore, authz, zeus, registryHandler, alertmanager, rulerInstance, statsAggregator)
// Initialize the API server (after registry so it can access service health)
apiserverInstance, err := factory.NewProviderFromNamedMap(

View File

@@ -0,0 +1,206 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
)
// storableSavedViewSelectFieldsRow is the shape of the `saved_view` table this migration repairs.
type storableSavedViewSelectFieldsRow struct {
bun.BaseModel `bun:"table:saved_view"`
ID string `bun:"id,pk,type:text"`
Data string `bun:"data,type:text"`
}
// selectedField is a superset of the current shape (name/signal/fieldContext/
// fieldDataType) and the legacy v1 shape (key/dataType/type) it replaced.
type selectedField struct {
Name string `json:"name"`
Signal string `json:"signal"`
FieldContext string `json:"fieldContext"`
FieldDataType string `json:"fieldDataType"`
Key string `json:"key"`
DataType string `json:"dataType"`
Type string `json:"type"`
}
// telemetryFieldKeyOutput is the current shape only.
type telemetryFieldKeyOutput struct {
Name string `json:"name"`
Signal string `json:"signal"`
FieldContext string `json:"fieldContext"`
FieldDataType string `json:"fieldDataType"`
}
// legacyTypeToFieldContext holds the legacy AttributeKeyType values with no matching
// telemetrytypes.FieldContext alias.
var legacyTypeToFieldContext = map[string]string{
"spanSearchScope": "span",
}
// legacyDataTypeToFieldDataType holds the legacy AttributeKeyDataType values with no
// matching telemetrytypes.FieldDataType alias.
var legacyDataTypeToFieldDataType = map[string]string{
"array(string)": "[]string",
"array(int64)": "[]int64",
"array(float64)": "[]float64",
"array(bool)": "[]bool",
}
func fieldContextFromLegacyType(legacyType string) string {
if mapped, ok := legacyTypeToFieldContext[legacyType]; ok {
return mapped
}
return legacyType
}
func fieldDataTypeFromLegacyDataType(legacyDataType string) string {
if mapped, ok := legacyDataTypeToFieldDataType[legacyDataType]; ok {
return mapped
}
return legacyDataType
}
type fixSavedViewSelectFields struct {
sqlstore sqlstore.SQLStore
settings factory.ProviderSettings
}
func NewFixSavedViewSelectFieldsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("fix_saved_view_select_fields"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &fixSavedViewSelectFields{sqlstore: sqlstore, settings: ps}, nil
})
}
func (migration *fixSavedViewSelectFields) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *fixSavedViewSelectFields) 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 []*storableSavedViewSelectFieldsRow
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
var fixed, skipped int
for _, row := range rows {
fixedData, changed, ok := fixSelectFields(row.Data)
if !ok {
migration.settings.Logger.WarnContext(ctx, "saved view data could not be parsed, leaving it untouched", slog.String("saved_view_id", row.ID), slog.String("raw_data", row.Data))
skipped++
continue
}
if !changed {
continue
}
fixed++
if _, err := tx.NewUpdate().Model((*storableSavedViewSelectFieldsRow)(nil)).Set("data = ?", fixedData).Where("id = ?", row.ID).Exec(ctx); err != nil {
return err
}
}
migration.settings.Logger.InfoContext(ctx, "fixed invalid saved view selectedFields entries", slog.Int("total", len(rows)), slog.Int("fixed", fixed), slog.Int("skipped", skipped))
return tx.Commit()
}
func (migration *fixSavedViewSelectFields) Down(context.Context, *bun.DB) error {
return nil
}
// fixSelectFields recovers or drops entries in spec.selectedFields that never got
// mapped from the legacy key/dataType/type shape to the current
// name/fieldContext/fieldDataType shape. Entries that still carry a legacy key are
// recovered by renaming the fields; entries with neither a name nor a key are dropped
// as unrecoverable. Returns ok=false if data can't be parsed at all, and changed=false
// if there was nothing to fix.
func fixSelectFields(data string) (fixed string, changed bool, ok bool) {
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(data), &raw); err != nil {
return "", false, false
}
var spec map[string]json.RawMessage
if err := json.Unmarshal(raw["spec"], &spec); err != nil {
return "", false, false
}
selectedFieldsRaw, ok := spec["selectedFields"]
if !ok {
return "", false, true
}
var fieldsRaw []json.RawMessage
if err := json.Unmarshal(selectedFieldsRaw, &fieldsRaw); err != nil {
return "", false, false
}
fixedFields := make([]json.RawMessage, 0, len(fieldsRaw))
for _, rawField := range fieldsRaw {
var field selectedField
if err := json.Unmarshal(rawField, &field); err != nil {
return "", false, false
}
switch {
case field.Name != "":
// already valid -- keep the original bytes untouched, e.g. to preserve
// description/unit rather than dropping them by re-deriving the entry.
fixedFields = append(fixedFields, rawField)
case field.Key != "":
// legacy shape -- recover by renaming the fields.
recoveredJSON, err := json.Marshal(telemetryFieldKeyOutput{
Name: field.Key,
FieldContext: fieldContextFromLegacyType(field.Type),
FieldDataType: fieldDataTypeFromLegacyDataType(field.DataType),
})
if err != nil {
return "", false, false
}
fixedFields = append(fixedFields, recoveredJSON)
changed = true
default:
// neither name nor key -- unrecoverable, drop it.
changed = true
}
}
if !changed {
return "", false, true
}
fixedFieldsJSON, err := json.Marshal(fixedFields)
if err != nil {
return "", false, false
}
spec["selectedFields"] = fixedFieldsJSON
fixedSpec, err := json.Marshal(spec)
if err != nil {
return "", false, false
}
raw["spec"] = fixedSpec
fixedData, err := json.Marshal(raw)
if err != nil {
return "", false, false
}
return string(fixedData), true, true
}

View File

@@ -92,6 +92,11 @@ func (s *SavedViewSpec) Validate() error {
if s.RequestType.IsZero() {
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "requestType is required")
}
for i, field := range s.SelectedFields {
if field.Name == "" {
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "selectedFields[%d].name is required", i)
}
}
return (&qbtypes.CompositeQuery{Queries: s.Queries}).Validate(qbtypes.GetValidationOptions(s.RequestType)...)
}

View File

@@ -99,6 +99,17 @@ func TestSavedViewSpecValidate(t *testing.T) {
},
expectError: false,
},
{
name: "selectedFields entry with no name is rejected",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeTable,
RequestType: qbtypes.RequestTypeScalar,
Queries: validQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}, {}},
},
expectError: true,
},
{
name: "nil selectedFields is valid -- selectedFields itself is not required",
spec: SavedViewSpec{

View File

@@ -1,66 +0,0 @@
import json
import math
import os
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from fixtures.metrics import Metrics
TESTDATA_DIR = os.path.join(os.path.dirname(__file__), "..", "integration", "testdata", "promqltestcorpus")
CORPUS_FILE = os.path.join(TESTDATA_DIR, "corpus.json")
# Datasets sit on disjoint time windows (2h gaps, far beyond the 5m lookback)
# so one bulk ingest serves every case without cross-talk.
ISOLATION_GAP_MS = 2 * 3600 * 1000
SPECIALS = {"NaN": math.nan, "Inf": math.inf, "-Inf": -math.inf}
def ingest_promqltest_corpus(insert_metrics: Callable[[list[Metrics]], None]) -> tuple[dict, dict[int, int]]:
"""Loads the frozen corpus, lays its datasets end to end on the timeline
(newest last, ending safely in the past), ingests every sample, and
returns (corpus, dataset base timestamps).
Dataset bases are hour-aligned: registration rows are hour-bucketed, so
behavior depends on where samples fall relative to hour boundaries, and
exact known-divergences enforcement needs identical placement every run."""
with open(CORPUS_FILE, encoding="utf-8") as f:
corpus = json.load(f)
cases_by_dataset: dict[int, list[dict]] = {}
for case in corpus["cases"]:
cases_by_dataset.setdefault(case["dataset"], []).append(case)
spans = {}
for ds in corpus["datasets"]:
sample_max = max((s["samples"][-1][0] for s in ds["series"] if s["samples"]), default=0)
case_max = max((c["end_ms"] for c in cases_by_dataset.get(ds["id"], [])), default=0)
spans[ds["id"]] = max(sample_max, case_max) + corpus["meta"]["lookback_ms"]
hour_ms = 3_600_000
advances = {ds["id"]: -(-(spans[ds["id"]] + ISOLATION_GAP_MS) // hour_ms) * hour_ms for ds in corpus["datasets"]}
total = sum(advances.values())
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
cursor = (int((now - timedelta(hours=1)).timestamp() * 1000) - total) // hour_ms * hour_ms
bases: dict[int, int] = {}
metrics: list[Metrics] = []
for ds in corpus["datasets"]:
bases[ds["id"]] = cursor
for series in ds["series"]:
labels = dict(series["labels"])
metric_name = labels.pop("__name__")
for off_ms, raw in series["samples"]:
stale = raw == "stale"
metrics.append(
Metrics(
metric_name=metric_name,
labels=labels,
timestamp=datetime.fromtimestamp((cursor + off_ms) / 1000, tz=UTC),
value=0.0 if stale else (SPECIALS[raw] if isinstance(raw, str) else float(raw)),
flags=1 if stale else 0,
)
)
cursor += advances[ds["id"]]
insert_metrics(metrics)
return corpus, bases

View File

@@ -1,138 +0,0 @@
import json
import math
from collections.abc import Callable
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
from fixtures.promqltestcorpus import ingest_promqltest_corpus
# The same frozen corpus the promqlconformance package replays through
# /api/v5/query_range, here replayed against the /prometheus/api/v1 endpoints
# with clickhousev2 as the serving provider (see conftest.py) — the two paths
# nothing else exercises. Range cases go to query_range, where a
# RangeExecutor provider serves transpiled statements when the shape allows.
# Instant cases go to /query with a real `time` parameter, so they need no
# grid encoding.
#
# Prometheus API sample values are strings, "NaN"/"+Inf"/"-Inf" included.
SPECIALS = {"NaN": math.nan, "Inf": math.inf, "+Inf": math.inf, "-Inf": -math.inf}
QUERY_TIMEOUT = 30
def test_prometheus_api_corpus(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
corpus, bases = ingest_promqltest_corpus(insert_metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
failures: list[str] = []
for case in corpus["cases"]:
# instant-coarse variants encode an instant eval as a coarse-step
# range because the v5 API cannot run true instants. This API can:
# the [base] form of the same eval goes through /query below, and the
# transpiled coarse-step serving the encoding exercises is covered
# (and its known divergences ledgered) by promqlconformance's
# clickhousev2 leg.
if case["variant"] == "instant-coarse":
continue
base = bases[case["dataset"]]
start_ms = base + case["start_ms"]
end_ms = base + case["end_ms"]
step_s = max(1, case["step_ms"] // 1000)
case_id = f"{case['source']}[{case['variant']}]"
if case["instant"]:
path, params = "/prometheus/api/v1/query", {"query": case["expr"], "time": end_ms / 1000}
else:
path, params = (
"/prometheus/api/v1/query_range",
{
"query": case["expr"],
"start": start_ms / 1000,
"end": end_ms / 1000,
"step": step_s,
},
)
response = requests.get(
signoz.self.host_configs["8080"].get(path),
params=params,
timeout=QUERY_TIMEOUT,
headers={"authorization": f"Bearer {token}"},
)
if response.status_code != HTTPStatus.OK:
failures.append(f"{case_id}: HTTP {response.status_code} for {case['expr']!r}: {response.text[:200]}")
continue
body = response.json()
if body.get("status") != "success":
failures.append(f"{case_id}: status {body.get('status')!r} for {case['expr']!r}: {json.dumps(body)[:200]}")
continue
result_type, result = body["data"]["resultType"], body["data"]["result"]
actual: dict[tuple, dict[int, float]] = {}
if result_type == "matrix":
for series in result:
points = {round(float(ts) * 1000): SPECIALS[v] if v in SPECIALS else float(v) for ts, v in series.get("values") or []}
actual[tuple(sorted((series.get("metric") or {}).items()))] = points
elif result_type == "vector":
for series in result:
ts, v = series["value"]
actual[tuple(sorted((series.get("metric") or {}).items()))] = {round(float(ts) * 1000): SPECIALS[v] if v in SPECIALS else float(v)}
elif result_type == "scalar":
ts, v = result
actual[()] = {round(float(ts) * 1000): SPECIALS[v] if v in SPECIALS else float(v)}
expected: dict[tuple, dict[int, float]] = {}
for res in case["expected"]:
points = {base + off_ms: SPECIALS[v] if isinstance(v, str) else float(v) for off_ms, v in res["points"]}
expected[tuple(sorted(res["labels"].items()))] = points
if set(actual) != set(expected):
missing = set(expected) - set(actual)
extra = set(actual) - set(expected)
failures.append(f"{case_id}: series mismatch for {case['expr']!r} (missing={sorted(missing)[:3]} extra={sorted(extra)[:3]})")
continue
mismatch = None
for lset, exp_points in expected.items():
act_points = actual[lset]
if set(act_points) != set(exp_points):
mismatch = f"{case_id}: timestamp mismatch for {case['expr']!r} series {dict(lset)} (expected {len(exp_points)} points, got {len(act_points)})"
break
for ts, exp_v in exp_points.items():
act_v = act_points[ts]
if math.isnan(act_v) or math.isnan(exp_v):
close = math.isnan(act_v) and math.isnan(exp_v)
elif math.isinf(act_v) or math.isinf(exp_v):
close = act_v == exp_v
elif act_v == exp_v:
close = True
else:
# Expected values carry the v5 API's rounding (>=1: three
# decimal places; <1: three significant digits); this API
# returns raw floats. One rounding quantum covers the
# largest possible rounding difference.
scale = max(abs(act_v), abs(exp_v))
if scale >= 1:
quantum = max(1e-3, scale * 1e-9)
else:
quantum = 10 ** (math.floor(math.log10(scale)) - 2)
close = abs(act_v - exp_v) <= quantum + 1e-12
if not close:
mismatch = f"{case_id}: value mismatch for {case['expr']!r} series {dict(lset)} at {ts}: expected {exp_v}, got {act_v}"
break
if mismatch:
break
if mismatch:
failures.append(mismatch)
for f_line in failures:
print("DIVERGED", f_line)
assert not failures, f"{len(failures)} corpus cases diverged:\n" + "\n".join(failures[:25])

View File

@@ -1,37 +0,0 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.signoz import create_signoz
@pytest.fixture(name="signoz", scope="package")
def signoz_promapi_v2(
network: Network,
migrator: types.Operation, # pylint: disable=unused-argument
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
"""
SigNoz with clickhousev2 as the serving prometheus provider. The corpus
replays against the /prometheus/api/v1 endpoints, so this package covers
the two paths nothing else serves: v2 as the provider (range queries
transpile when the shape allows), and the Prometheus HTTP API contract.
"""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz-promapi-v2",
env_overrides={
"SIGNOZ_PROMETHEUS_PROVIDER": "clickhousev2",
},
)

View File

@@ -2,21 +2,21 @@ import json
import math
import os
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
from fixtures.promqltestcorpus import ingest_promqltest_corpus
from fixtures.querier import get_all_series, make_query_request
TESTDATA_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "testdata")
# The corpus (see fixtures/promqltestcorpus.py) is frozen from Prometheus' own
# promql/promqltest testdata by scripts/promqltestcorpus (upstream load scripts
# + the vendored reference engine). Unlike live-vs-live parity suites, the
# oracle is a committed file, so the suite keeps working when the serving path
# itself is the thing being changed — the one situation where comparing two
# live paths against each other is blind.
# Frozen corpus extracted from Prometheus' own promql/promqltest testdata by
# scripts/promqltestcorpus (upstream load scripts + the vendored reference engine).
# Unlike live-vs-live parity suites, the oracle is this committed file, so the suite
# keeps working when the serving path itself is the thing being changed — the one
# situation where comparing two live paths against each other is blind.
CORPUS_FILE = os.path.join(TESTDATA_DIR, "promqltestcorpus", "corpus.json")
# One ledger per leg, enforced exactly in both directions. The default leg's
# ledger is empty and pinned there; the clickhousev2 ledger is the rollout
@@ -40,6 +40,9 @@ LEGS: list[tuple[str, dict | None]] = [
("clickhousev2", {"X-SigNoz-PromQL-Provider": "clickhousev2"}),
]
# Datasets sit on disjoint time windows (2h gaps, far beyond the 5m lookback) so
# one bulk ingest serves every case without cross-talk.
ISOLATION_GAP_MS = 2 * 3600 * 1000
SPECIALS = {"NaN": math.nan, "Inf": math.inf, "-Inf": -math.inf}
@@ -49,7 +52,51 @@ def test_upstream_promqltest_corpus(
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
corpus, bases = ingest_promqltest_corpus(insert_metrics)
with open(CORPUS_FILE, encoding="utf-8") as f:
corpus = json.load(f)
cases_by_dataset: dict[int, list[dict]] = {}
for case in corpus["cases"]:
cases_by_dataset.setdefault(case["dataset"], []).append(case)
# Lay datasets end to end on the timeline, newest last, ending safely in
# the past; spans are per-dataset so the whole corpus stays within days.
spans = {}
for ds in corpus["datasets"]:
sample_max = max((s["samples"][-1][0] for s in ds["series"] if s["samples"]), default=0)
case_max = max((c["end_ms"] for c in cases_by_dataset.get(ds["id"], [])), default=0)
spans[ds["id"]] = max(sample_max, case_max) + corpus["meta"]["lookback_ms"]
# Hour-aligned dataset bases: registration rows are hour-bucketed, so
# behavior depends on where samples fall relative to hour boundaries —
# the exact known-divergences enforcement needs that identical every run.
hour_ms = 3_600_000
advances = {ds["id"]: -(-(spans[ds["id"]] + ISOLATION_GAP_MS) // hour_ms) * hour_ms for ds in corpus["datasets"]}
total = sum(advances.values())
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
cursor = (int((now - timedelta(hours=1)).timestamp() * 1000) - total) // hour_ms * hour_ms
bases: dict[int, int] = {}
metrics: list[Metrics] = []
for ds in corpus["datasets"]:
bases[ds["id"]] = cursor
for series in ds["series"]:
labels = dict(series["labels"])
metric_name = labels.pop("__name__")
for off_ms, raw in series["samples"]:
stale = raw == "stale"
metrics.append(
Metrics(
metric_name=metric_name,
labels=labels,
timestamp=datetime.fromtimestamp((cursor + off_ms) / 1000, tz=UTC),
value=0.0 if stale else (SPECIALS[raw] if isinstance(raw, str) else float(raw)),
flags=1 if stale else 0,
)
)
cursor += advances[ds["id"]]
insert_metrics(metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
failures: dict[str, list[str]] = {leg: [] for leg, _ in LEGS}