Skip to content

Commit b511c14

Browse files
committed
fix(grafana): correct response contracts, required alert fields, and outbound request hardening
Validated against Grafana's HTTP API reference and, where the docs contradict themselves, against the Go wire structs. Response shapes the tools got wrong: - update_annotation declared an `id` that was always 0; a patch returns only a message, so the request's annotation id is echoed and labelled as such - delete_folder discarded the numeric id Grafana returns and presented an input-echoed uid as if it came from the API - delete_dashboard fabricated `id: 0` / `title: ''` via `||` on absent fields - the contact-point `provenance` description was inverted: "api" means API-managed, empty means it stayed UI-editable Requests that could not succeed: - create_alert_rule left noDataState and execErrState unset and invisible to the model, but Grafana's validator rejects an empty value outright, so every model-driven create failed. Both are now sent with Grafana's own defaults, and skipped for recording rules, which take a different validator - get_data_source routed a numeric input at /api/datasources/:id, which exists only behind an off-by-default feature toggle. UID only now - list_annotations did not trim the dashboard UID, so a padded value matched nothing Outbound hardening on the three proxy routes: - the service-account token was re-sent to redirect targets; the shared fetch only drops it when asked, so stripAuthOnRedirect is now set - no timeout was passed, leaving two sequential hops at the 5-minute default - upstream error bodies were interpolated whole into the tool result, putting up to 10MB of HTML into logs and traces; now truncated - UID path segments are URL-encoded so they cannot re-target the request - update_folder sent both `version` and `overwrite: true`, which Grafana treats as alternatives, making the freshly fetched version decorative and silently clobbering a concurrent rename - replaced the `any` casts with narrowed types Block surface: - 25 outputs the tools emit were undeclared and so unreferenceable downstream; get_data_source had 13 of its 18 unreachable - `version` was typed string though the dashboard, folder, and data-source producers all emit a number - the dashboard title field was shown only for create, so a dashboard could never be renamed through Update Dashboard - six list outputs were typed json rather than array
1 parent c817f11 commit b511c14

17 files changed

Lines changed: 215 additions & 77 deletions

File tree

apps/docs/content/docs/en/integrations/grafana.mdx

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ List all alert rules in the Grafana instance
207207
|`folderUID` | string | Parent folder UID |
208208
|`ruleGroup` | string | Rule group name |
209209
|`orgID` | number | Organization ID |
210-
|`provenance` | string | Provisioning source \(empty if API-managed\) |
210+
|`provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI |
211211
|`notification_settings` | json | Per-rule notification settings \(overrides\) |
212212
|`record` | json | Recording rule configuration \(recording rules only\) |
213213

@@ -245,7 +245,7 @@ Get a specific alert rule by its UID
245245
| `folderUID` | string | Parent folder UID |
246246
| `ruleGroup` | string | Rule group name |
247247
| `orgID` | number | Organization ID |
248-
| `provenance` | string | Provisioning source \(empty if API-managed\) |
248+
| `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI |
249249
| `notification_settings` | json | Per-rule notification settings \(overrides\) |
250250
| `record` | json | Recording rule configuration \(recording rules only\) |
251251

@@ -266,8 +266,8 @@ Create a new alert rule
266266
| `condition` | string | No | The refId of the query or expression to use as the alert condition \(required for alerting rules; omit for recording rules\) |
267267
| `data` | string | Yes | JSON array of query/expression data objects |
268268
| `forDuration` | string | No | Duration to wait before firing \(e.g., 5m, 1h\) |
269-
| `noDataState` | string | No | State when no data is returned \(NoData, Alerting, OK\) |
270-
| `execErrState` | string | No | State on execution error \(Error, Alerting, OK\) |
269+
| `noDataState` | string | No | State when no data is returned: NoData \(default\), Alerting, OK, or KeepLast. Ignored for recording rules |
270+
| `execErrState` | string | No | State on execution error: Error \(default\), Alerting, OK, or KeepLast. Ignored for recording rules |
271271
| `annotations` | string | No | JSON object of annotations |
272272
| `labels` | string | No | JSON object of labels |
273273
| `uid` | string | No | Optional custom UID for the alert rule |
@@ -299,7 +299,7 @@ Create a new alert rule
299299
| `folderUID` | string | Parent folder UID |
300300
| `ruleGroup` | string | Rule group name |
301301
| `orgID` | number | Organization ID |
302-
| `provenance` | string | Provisioning source \(empty if API-managed\) |
302+
| `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI |
303303
| `notification_settings` | json | Per-rule notification settings \(overrides\) |
304304
| `record` | json | Recording rule configuration \(recording rules only\) |
305305

@@ -353,7 +353,7 @@ Update an existing alert rule. Fetches the current rule and merges your changes.
353353
| `folderUID` | string | Parent folder UID |
354354
| `ruleGroup` | string | Rule group name |
355355
| `orgID` | number | Organization ID |
356-
| `provenance` | string | Provisioning source \(empty if API-managed\) |
356+
| `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI |
357357
| `notification_settings` | json | Per-rule notification settings \(overrides\) |
358358
| `record` | json | Recording rule configuration \(recording rules only\) |
359359

@@ -399,7 +399,7 @@ List all alert notification contact points
399399
|`type` | string | Notification type \(email, slack, etc.\) |
400400
|`settings` | object | Type-specific settings |
401401
|`disableResolveMessage` | boolean | Whether resolve messages are disabled |
402-
|`provenance` | string | Provisioning source \(empty if API-managed\) |
402+
|`provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI |
403403

404404
### Grafana Create Contact Point
405405

@@ -427,7 +427,7 @@ Create a notification contact point (e.g., Slack, email, PagerDuty)
427427
| `type` | string | Receiver type |
428428
| `settings` | json | Type-specific settings |
429429
| `disableResolveMessage` | boolean | Whether resolve notifications are suppressed |
430-
| `provenance` | string | Provisioning source \(empty if API-managed\) |
430+
| `provenance` | string | Provisioning source — "api" for API-managed, empty when created with X-Disable-Provenance and therefore still editable in the Grafana UI |
431431

432432
### Grafana Create Annotation
433433

@@ -474,7 +474,7 @@ Query annotations by time range, dashboard, or tags
474474
| `userId` | number | No | Filter by ID of the user who created the annotation |
475475
| `tags` | string | No | Comma-separated list of tags to filter by |
476476
| `type` | string | No | Filter by type \(alert or annotation\) |
477-
| `limit` | number | No | Maximum number of annotations to return |
477+
| `limit` | number | No | Maximum number of annotations to return \(Grafana defaults to 100\) |
478478

479479
#### Output
480480

@@ -518,8 +518,8 @@ Update an existing annotation
518518

519519
| Parameter | Type | Description |
520520
| --------- | ---- | ----------- |
521-
| `id` | number | The ID of the updated annotation |
522-
| `message` | string | Confirmation message |
521+
| `annotationId` | number | The annotation that was updated, echoed from the request — Grafana answers a patch with only a message and returns no id |
522+
| `message` | string | Confirmation message from Grafana, e.g. "Annotation patched" |
523523

524524
### Grafana Delete Annotation
525525

@@ -587,7 +587,7 @@ Get a data source by its ID or UID
587587
| `apiKey` | string | Yes | Grafana Service Account Token |
588588
| `baseUrl` | string | Yes | Grafana instance URL \(e.g., https://your-grafana.com\) |
589589
| `organizationId` | string | No | Organization ID for multi-org Grafana instances \(e.g., 1, 2\) |
590-
| `dataSourceId` | string | Yes | The ID or UID of the data source to retrieve \(e.g., prometheus, P1234AB5678\) |
590+
| `dataSourceId` | string | Yes | The UID of the data source to retrieve \(e.g., P1234AB5678\). Numeric ids are not supported — Grafana serves those only behind a disabled-by-default feature toggle |
591591

592592
#### Output
593593

@@ -788,8 +788,9 @@ Delete a folder by its UID
788788

789789
| Parameter | Type | Description |
790790
| --------- | ---- | ----------- |
791-
| `uid` | string | The UID of the deleted folder |
792-
| `message` | string | Confirmation message |
791+
| `id` | number | Numeric id of the deleted folder, as returned by Grafana |
792+
| `uid` | string | The UID that was deleted, echoed from the request |
793+
| `message` | string | Grafana's confirmation message |
793794

794795
### Grafana Get Health
795796

apps/sim/app/api/tools/grafana/update_alert_rule/route.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
3+
import { truncate } from '@sim/utils/string'
34
import { type NextRequest, NextResponse } from 'next/server'
45
import { grafanaUpdateAlertRuleContract } from '@/lib/api/contracts/tools/grafana'
56
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
@@ -17,6 +18,11 @@ export const dynamic = 'force-dynamic'
1718

1819
const logger = createLogger('GrafanaUpdateAlertRuleAPI')
1920

21+
/** Grafana is reached over two sequential hops, so each one needs its own bound. */
22+
const OUTBOUND_FETCH_TIMEOUT_MS = 30_000
23+
/** Upstream error bodies can be a full HTML page; only a prefix is useful. */
24+
const MAX_ERROR_MESSAGE_LENGTH = 2000
25+
2026
export const POST = withRouteHandler(async (request: NextRequest) => {
2127
const requestId = generateRequestId()
2228

@@ -64,7 +70,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6470
getHeaders['X-Grafana-Org-Id'] = params.organizationId
6571
}
6672

67-
const getUrl = `${baseUrl}/api/v1/provisioning/alert-rules/${params.alertRuleUid.trim()}`
73+
const getUrl = `${baseUrl}/api/v1/provisioning/alert-rules/${encodeURIComponent(params.alertRuleUid.trim())}`
6874
const getValidation = await validateUrlWithDNS(getUrl, 'baseUrl')
6975
if (!getValidation.isValid || !getValidation.resolvedIP) {
7076
return NextResponse.json({
@@ -78,18 +84,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
7884
method: 'GET',
7985
headers: getHeaders,
8086
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
87+
timeout: OUTBOUND_FETCH_TIMEOUT_MS,
88+
stripAuthOnRedirect: true,
8189
})
8290

8391
if (!getResponse.ok) {
84-
const errorText = await getResponse.text()
92+
const errorText = truncate(await getResponse.text(), MAX_ERROR_MESSAGE_LENGTH)
8593
return NextResponse.json({
8694
success: false,
8795
output: {},
8896
error: `Failed to fetch existing alert rule: ${errorText}`,
8997
})
9098
}
9199

92-
const existingRule = (await getResponse.json()) as any
100+
const existingRule = (await getResponse.json()) as Record<string, unknown>
93101

94102
if (!existingRule || !existingRule.uid) {
95103
return NextResponse.json({
@@ -193,7 +201,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
193201
headers['X-Disable-Provenance'] = 'true'
194202
}
195203

196-
const updateUrl = `${baseUrl}/api/v1/provisioning/alert-rules/${params.alertRuleUid.trim()}`
204+
const updateUrl = `${baseUrl}/api/v1/provisioning/alert-rules/${encodeURIComponent(params.alertRuleUid.trim())}`
197205
const urlValidation = await validateUrlWithDNS(updateUrl, 'baseUrl')
198206
if (!urlValidation.isValid || !urlValidation.resolvedIP) {
199207
return NextResponse.json({
@@ -208,10 +216,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
208216
headers,
209217
body: JSON.stringify(updatedRule),
210218
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
219+
timeout: OUTBOUND_FETCH_TIMEOUT_MS,
220+
stripAuthOnRedirect: true,
211221
})
212222

213223
if (!updateResponse.ok) {
214-
const errorText = await updateResponse.text()
224+
const errorText = truncate(await updateResponse.text(), MAX_ERROR_MESSAGE_LENGTH)
215225
return NextResponse.json({
216226
success: false,
217227
output: {},

apps/sim/app/api/tools/grafana/update_dashboard/route.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
3+
import { truncate } from '@sim/utils/string'
34
import { type NextRequest, NextResponse } from 'next/server'
45
import { grafanaUpdateDashboardContract } from '@/lib/api/contracts/tools/grafana'
56
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
@@ -16,6 +17,11 @@ export const dynamic = 'force-dynamic'
1617

1718
const logger = createLogger('GrafanaUpdateDashboardAPI')
1819

20+
/** Grafana is reached over two sequential hops, so each one needs its own bound. */
21+
const OUTBOUND_FETCH_TIMEOUT_MS = 30_000
22+
/** Upstream error bodies can be a full HTML page; only a prefix is useful. */
23+
const MAX_ERROR_MESSAGE_LENGTH = 2000
24+
1925
export const POST = withRouteHandler(async (request: NextRequest) => {
2026
const requestId = generateRequestId()
2127

@@ -63,7 +69,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6369
getHeaders['X-Grafana-Org-Id'] = params.organizationId
6470
}
6571

66-
const getUrl = `${baseUrl}/api/dashboards/uid/${params.dashboardUid.trim()}`
72+
const getUrl = `${baseUrl}/api/dashboards/uid/${encodeURIComponent(params.dashboardUid.trim())}`
6773
const getValidation = await validateUrlWithDNS(getUrl, 'baseUrl')
6874
if (!getValidation.isValid || !getValidation.resolvedIP) {
6975
return NextResponse.json({
@@ -77,18 +83,28 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
7783
method: 'GET',
7884
headers: getHeaders,
7985
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
86+
timeout: OUTBOUND_FETCH_TIMEOUT_MS,
87+
stripAuthOnRedirect: true,
8088
})
8189

8290
if (!getResponse.ok) {
83-
const errorText = await getResponse.text()
91+
const errorText = truncate(await getResponse.text(), MAX_ERROR_MESSAGE_LENGTH)
8492
return NextResponse.json({
8593
success: false,
8694
output: {},
8795
error: `Failed to fetch existing dashboard: ${errorText}`,
8896
})
8997
}
9098

91-
const existing = (await getResponse.json()) as any
99+
/**
100+
* `GET /api/dashboards/uid/:uid` answers `{dashboard, meta}`. Only the few
101+
* fields this route reads are narrowed — the rest of the dashboard is
102+
* arbitrary user JSON that is spread through untouched.
103+
*/
104+
const existing = (await getResponse.json()) as {
105+
dashboard?: Record<string, unknown>
106+
meta?: { folderUid?: string }
107+
}
92108
const existingDashboard = existing.dashboard
93109
const existingMeta = existing.meta
94110

@@ -100,7 +116,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
100116
})
101117
}
102118

103-
const updatedDashboard: Record<string, any> = {
119+
const updatedDashboard: Record<string, unknown> = {
104120
...existingDashboard,
105121
}
106122

@@ -131,7 +147,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
131147
updatedDashboard.version = existingDashboard.version
132148
}
133149

134-
const body: Record<string, any> = {
150+
const body: Record<string, unknown> = {
135151
dashboard: updatedDashboard,
136152
overwrite: params.overwrite === true,
137153
}
@@ -169,10 +185,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
169185
headers,
170186
body: JSON.stringify(body),
171187
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
188+
timeout: OUTBOUND_FETCH_TIMEOUT_MS,
189+
stripAuthOnRedirect: true,
172190
})
173191

174192
if (!updateResponse.ok) {
175-
const errorText = await updateResponse.text()
193+
const errorText = truncate(await updateResponse.text(), MAX_ERROR_MESSAGE_LENGTH)
176194
return NextResponse.json({
177195
success: false,
178196
output: {},

apps/sim/app/api/tools/grafana/update_folder/route.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
3+
import { truncate } from '@sim/utils/string'
34
import { type NextRequest, NextResponse } from 'next/server'
45
import { grafanaUpdateFolderContract } from '@/lib/api/contracts/tools/grafana'
56
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
@@ -16,6 +17,11 @@ export const dynamic = 'force-dynamic'
1617

1718
const logger = createLogger('GrafanaUpdateFolderAPI')
1819

20+
/** Grafana is reached over two sequential hops, so each one needs its own bound. */
21+
const OUTBOUND_FETCH_TIMEOUT_MS = 30_000
22+
/** Upstream error bodies can be a full HTML page; only a prefix is useful. */
23+
const MAX_ERROR_MESSAGE_LENGTH = 2000
24+
1925
export const POST = withRouteHandler(async (request: NextRequest) => {
2026
const requestId = generateRequestId()
2127

@@ -61,7 +67,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6167
headers['X-Grafana-Org-Id'] = params.organizationId
6268
}
6369

64-
const folderUrl = `${baseUrl}/api/folders/${params.folderUid.trim()}`
70+
const folderUrl = `${baseUrl}/api/folders/${encodeURIComponent(params.folderUid.trim())}`
6571
const urlValidation = await validateUrlWithDNS(folderUrl, 'baseUrl')
6672
if (!urlValidation.isValid || !urlValidation.resolvedIP) {
6773
return NextResponse.json({
@@ -75,18 +81,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
7581
method: 'GET',
7682
headers,
7783
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
84+
timeout: OUTBOUND_FETCH_TIMEOUT_MS,
85+
stripAuthOnRedirect: true,
7886
})
7987

8088
if (!getResponse.ok) {
81-
const errorText = await getResponse.text()
89+
const errorText = truncate(await getResponse.text(), MAX_ERROR_MESSAGE_LENGTH)
8290
return NextResponse.json({
8391
success: false,
8492
output: {},
8593
error: `Failed to fetch existing folder: ${errorText}`,
8694
})
8795
}
8896

89-
const existingFolder = (await getResponse.json()) as any
97+
const existingFolder = (await getResponse.json()) as Record<string, unknown>
9098

9199
if (!existingFolder || !existingFolder.uid) {
92100
return NextResponse.json({
@@ -96,21 +104,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
96104
})
97105
}
98106

107+
/**
108+
* Grafana treats `version` and `overwrite` as alternatives: `version` is
109+
* "not needed if overwrite=true". Sending both made the version we just
110+
* fetched decorative and silently clobbered a concurrent rename, so only
111+
* the version is sent and a conflicting edit surfaces as Grafana's 412
112+
* instead of being lost.
113+
*/
99114
const body: Record<string, unknown> = {
100-
title: params.title ?? existingFolder.title,
115+
title: params.title,
101116
version: existingFolder.version,
102-
overwrite: true,
103117
}
104118

105119
const updateResponse = await secureFetchWithPinnedIP(folderUrl, urlValidation.resolvedIP, {
106120
method: 'PUT',
107121
headers,
108122
body: JSON.stringify(body),
109123
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
124+
timeout: OUTBOUND_FETCH_TIMEOUT_MS,
125+
stripAuthOnRedirect: true,
110126
})
111127

112128
if (!updateResponse.ok) {
113-
const errorText = await updateResponse.text()
129+
const errorText = truncate(await updateResponse.text(), MAX_ERROR_MESSAGE_LENGTH)
114130
return NextResponse.json({
115131
success: false,
116132
output: {},

0 commit comments

Comments
 (0)