Skip to content

Commit b953811

Browse files
committed
docs(dynatrace): add the page intro, and pin every response key in tests
Adds a MANUAL-CONTENT:intro block to the generated integration page covering what the block reaches, how to get an environment URL and a scoped token for SaaS vs Managed, how selectors work, and how cursor pagination behaves. Verified it survives `generate-docs.ts` byte-identically. Also closes the last silent-failure gap the validation pass left open. A wrong top-level response key does not throw — it maps to an empty array and reads as "no results", which is indistinguishable from a genuinely empty environment. Dynatrace is unusually easy to get wrong here: the SLO list returns `slo` (singular) and the metric query returns `result` (singular). Adds a table-driven test asserting the documented key for all ten list endpoints plus the scalar keys of the ingest and single-entity responses. Confirmed it bites by flipping `data.slo` to `data.slos` and watching only that row fail.
1 parent fde3489 commit b953811

2 files changed

Lines changed: 212 additions & 0 deletions

File tree

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,52 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
1010
color="#FFFFFF"
1111
/>
1212

13+
{/* MANUAL-CONTENT-START:intro */}
14+
[Dynatrace](https://www.dynatrace.com/) is an observability platform that monitors applications, infrastructure, and user experience from a single agent. Its Davis AI correlates signals across the stack into **problems** — a single incident with a root cause, an impact assessment, and the affected entities attached — instead of a stream of disconnected alerts.
15+
16+
**What you can reach from Sim**
17+
18+
- **Problems** — list and inspect Davis problems, read their root cause and affected entities, close them, and add comments.
19+
- **Metrics** — query time series with a metric selector, discover which metrics exist, read a metric's descriptor, and push your own data points.
20+
- **Entities** — list and inspect monitored hosts, services, applications, and Kubernetes workloads, and enumerate the entity types available for building selectors.
21+
- **Events** — read deployments, availability changes, and annotations, and ingest your own.
22+
- **Logs** — search log records and ingest new ones.
23+
- **SLOs** — read service-level objectives with their attainment, error budget, and burn rate.
24+
- **Application Security** — list and inspect vulnerabilities with risk assessment and remediation guidance.
25+
- **Audit log** — read who changed which configuration, and when.
26+
27+
**Setup**
28+
29+
You need two values: your **environment URL** and an **access token**.
30+
31+
The environment URL is the base address of your Dynatrace environment, without the API path:
32+
33+
| Deployment | Environment URL |
34+
| --- | --- |
35+
| SaaS | `https://abc12345.live.dynatrace.com` |
36+
| Managed / environment ActiveGate | `https://your-activegate:9999/e/abc12345` |
37+
38+
Create the token under **Access tokens** in Dynatrace, and grant only the scopes for the operations you plan to call. Each action's `apiToken` description names the scope it needs — `problems.read`, `metrics.read`, `entities.read`, `events.read`, `logs.read`, `slo.read`, `securityProblems.read`, `auditLogs.read` for reads, and `problems.write`, `metrics.ingest`, `events.ingest`, `logs.ingest` for writes.
39+
40+
**Selectors**
41+
42+
Most read operations are scoped by a selector rather than by fixed filter fields. Criteria are comma-separated, and every criterion matched must hold:
43+
44+
```
45+
entitySelector: type("HOST"),tag("env:prod")
46+
problemSelector: status("open"),severityLevel("AVAILABILITY")
47+
metricSelector: builtin:host.cpu.usage:splitBy("dt.entity.host"):avg:names
48+
securityProblemSelector: status("OPEN"),riskLevel("CRITICAL")
49+
```
50+
51+
Every selector field in the block has a wand — describe what you want in plain language and Sim writes the selector for you.
52+
53+
**Pagination**
54+
55+
List operations return a `nextPageKey` (a `nextSliceKey` for log search). Feed it back into the next call to read the following page. Dynatrace encodes the original filters into the cursor, so Sim sends the cursor alone and ignores the other filters on that call — which is what the API requires.
56+
{/* MANUAL-CONTENT-END */}
57+
58+
1359
## Usage Instructions
1460

1561
Integrate Dynatrace into workflows. Investigate and close Davis problems, query metrics and monitored entities, search and ingest logs, push deployment events, track SLO burn rates, review Application Security vulnerabilities, and read the audit log.

apps/sim/tools/dynatrace/dynatrace.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,25 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5+
import { closeProblemTool } from '@/tools/dynatrace/close_problem'
56
import { getAuditLogsTool } from '@/tools/dynatrace/get_audit_logs'
67
import { getEntityTool } from '@/tools/dynatrace/get_entity'
78
import { getMetricTool } from '@/tools/dynatrace/get_metric'
89
import { getProblemTool } from '@/tools/dynatrace/get_problem'
10+
import { getSloTool } from '@/tools/dynatrace/get_slo'
911
import { ingestEventTool } from '@/tools/dynatrace/ingest_event'
1012
import { ingestLogsTool } from '@/tools/dynatrace/ingest_logs'
13+
import { ingestMetricsTool } from '@/tools/dynatrace/ingest_metrics'
14+
import { listEntitiesTool } from '@/tools/dynatrace/list_entities'
15+
import { listEntityTypesTool } from '@/tools/dynatrace/list_entity_types'
16+
import { listEventsTool } from '@/tools/dynatrace/list_events'
17+
import { listMetricsTool } from '@/tools/dynatrace/list_metrics'
18+
import { listProblemCommentsTool } from '@/tools/dynatrace/list_problem_comments'
1119
import { listProblemsTool } from '@/tools/dynatrace/list_problems'
20+
import { listSecurityProblemsTool } from '@/tools/dynatrace/list_security_problems'
21+
import { listSlosTool } from '@/tools/dynatrace/list_slos'
22+
import { queryMetricsTool } from '@/tools/dynatrace/query_metrics'
23+
import { searchLogsTool } from '@/tools/dynatrace/search_logs'
1224
import { buildDynatraceUrl, dynatraceHeaders } from '@/tools/dynatrace/utils'
1325
import { ErrorExtractorId, extractErrorMessageWithId } from '@/tools/error-extractors'
1426

@@ -246,6 +258,160 @@ describe('response mapping', () => {
246258
expect(result.output).toEqual({ accepted: true, statusCode: 204, details: null })
247259
})
248260

261+
/**
262+
* A wrong top-level key does not throw — it yields an empty list and looks like
263+
* "no results". Each payload below is shaped exactly like the documented schema,
264+
* so an incorrect key fails loudly here instead of silently in production.
265+
*/
266+
it('reads the documented top-level key of every list response', async () => {
267+
const cases: Array<{
268+
name: string
269+
tool: { transformResponse?: (r: Response) => Promise<{ output: Record<string, never> }> }
270+
payload: Record<string, unknown>
271+
read: (out: Record<string, never>) => unknown
272+
}> = [
273+
{
274+
name: 'GET /slo -> slo',
275+
tool: listSlosTool,
276+
payload: { totalCount: 1, slo: [{ id: 'SLO-1', name: 'Checkout', status: 'WARNING' }] },
277+
read: (o) => o.slos,
278+
},
279+
{
280+
name: 'GET /metrics/query -> result',
281+
tool: queryMetricsTool,
282+
payload: {
283+
resolution: '1h',
284+
result: [
285+
{
286+
metricId: 'builtin:host.cpu.usage',
287+
data: [{ dimensions: ['HOST-1'], timestamps: [1], values: [42.5] }],
288+
},
289+
],
290+
},
291+
read: (o) => o.result,
292+
},
293+
{
294+
name: 'GET /metrics -> metrics',
295+
tool: listMetricsTool,
296+
payload: { totalCount: 1, metrics: [{ metricId: 'builtin:host.cpu.usage' }] },
297+
read: (o) => o.metrics,
298+
},
299+
{
300+
name: 'GET /entities -> entities',
301+
tool: listEntitiesTool,
302+
payload: { totalCount: 1, entities: [{ entityId: 'HOST-1', type: 'HOST' }] },
303+
read: (o) => o.entities,
304+
},
305+
{
306+
name: 'GET /entityTypes -> types',
307+
tool: listEntityTypesTool,
308+
payload: { totalCount: 1, types: [{ type: 'HOST', displayName: 'Host' }] },
309+
read: (o) => o.types,
310+
},
311+
{
312+
name: 'GET /events -> events',
313+
tool: listEventsTool,
314+
payload: { totalCount: 1, events: [{ eventId: 'E-1', eventType: 'CUSTOM_DEPLOYMENT' }] },
315+
read: (o) => o.events,
316+
},
317+
{
318+
name: 'GET /securityProblems -> securityProblems',
319+
tool: listSecurityProblemsTool,
320+
payload: {
321+
totalCount: 1,
322+
securityProblems: [{ securityProblemId: 'S-1', status: 'OPEN' }],
323+
},
324+
read: (o) => o.securityProblems,
325+
},
326+
{
327+
name: 'GET /logs/search -> results',
328+
tool: searchLogsTool,
329+
payload: { sliceSize: 1, results: [{ timestamp: 1, status: 'ERROR', content: 'boom' }] },
330+
read: (o) => o.results,
331+
},
332+
{
333+
name: 'GET /problems/{id}/comments -> comments',
334+
tool: listProblemCommentsTool,
335+
payload: { totalCount: 1, comments: [{ id: 'C-1', content: 'looking into it' }] },
336+
read: (o) => o.comments,
337+
},
338+
{
339+
name: 'GET /auditlogs -> auditLogs',
340+
tool: getAuditLogsTool,
341+
payload: { totalCount: 1, auditLogs: [{ logId: 'L-1' }] },
342+
read: (o) => o.auditLogs,
343+
},
344+
]
345+
346+
for (const { name, tool, payload, read } of cases) {
347+
const out = (
348+
await tool.transformResponse!(new Response(JSON.stringify(payload), { status: 200 }))
349+
).output
350+
expect(read(out), `${name} produced an empty list`).toHaveLength(1)
351+
}
352+
})
353+
354+
it('reads the documented scalar keys of the ingest and single-entity responses', async () => {
355+
const metrics = (
356+
await ingestMetricsTool.transformResponse!(
357+
new Response(JSON.stringify({ linesOk: 7, linesInvalid: 1, error: { code: 400 } }), {
358+
status: 202,
359+
})
360+
)
361+
).output
362+
expect(metrics.linesOk).toBe(7)
363+
expect(metrics.linesInvalid).toBe(1)
364+
expect(metrics.ingestError).toEqual({ code: 400 })
365+
366+
const event = (
367+
await ingestEventTool.transformResponse!(
368+
new Response(
369+
JSON.stringify({
370+
reportCount: 1,
371+
eventIngestResults: [{ correlationId: 'c-1', status: 'OK' }],
372+
}),
373+
{ status: 201 }
374+
)
375+
)
376+
).output
377+
expect(event.reportCount).toBe(1)
378+
expect(event.eventIngestResults).toEqual([{ correlationId: 'c-1', status: 'OK' }])
379+
380+
// Single-entity endpoints return the object at the document root, not nested.
381+
const slo = (
382+
await getSloTool.transformResponse!(
383+
new Response(JSON.stringify({ id: 'SLO-1', name: 'Checkout', evaluatedPercentage: 99.5 }), {
384+
status: 200,
385+
})
386+
)
387+
).output
388+
expect(slo.slo.id).toBe('SLO-1')
389+
expect(slo.slo.evaluatedPercentage).toBe(99.5)
390+
391+
const entity = (
392+
await getEntityTool.transformResponse!(
393+
new Response(JSON.stringify({ entityId: 'HOST-1', displayName: 'web-01' }), { status: 200 })
394+
)
395+
).output
396+
expect(entity.entity.entityId).toBe('HOST-1')
397+
398+
const closed = (
399+
await closeProblemTool.transformResponse!(
400+
new Response(
401+
JSON.stringify({
402+
problemId: 'P-1',
403+
closeTimestamp: 123,
404+
closing: true,
405+
comment: { id: 'C-1', content: 'fixed' },
406+
}),
407+
{ status: 200 }
408+
)
409+
)
410+
).output
411+
expect(closed.problemId).toBe('P-1')
412+
expect(closed.comment?.content).toBe('fixed')
413+
})
414+
249415
it('surfaces a 200 partial-success log ingestion body', async () => {
250416
const response = new Response(JSON.stringify({ error: { message: 'some invalid' } }), {
251417
status: 200,

0 commit comments

Comments
 (0)