Send audit events and drift to your tools
On Enterprise, an organisation can send every event of its audit log, and every drift run its CI reports, to the tools it already runs: Datadog, Dynatrace, Splunk, Microsoft Sentinel, or an HTTPS webhook of its own. Plotwork sends each one as it happens, in batches, and tries again for a day when a tool is down.
TL;DR
| Question | Answer |
|---|---|
| Which plan? | Enterprise. On another plan the Integrations tab says so, nothing is sent, and a destination saved earlier can still be removed. |
| Who sets it up? | The owner and the admins, signed in, on the organisation's Integrations tab in the portal. |
| Where can it go? | Datadog (logs, and drift as metrics), Dynatrace (logs, and drift as metrics), Splunk (HTTP Event Collector), Microsoft Sentinel (the Azure Monitor Logs Ingestion API), or a webhook signed with HMAC-SHA256. Up to five destinations. |
| What is sent? | Metadata only: the same fields as the audit log's export and the drift dashboard. Never a diagram's source, a key's value or a token. |
| How fast? | About five seconds after an event, in batches of up to 100. |
| What if my tool is down? | Plotwork tries again after 30 seconds, then waits twice as long each time, up to an hour, for a day. After a day the events are dropped and counted. |
| How are secrets kept? | Sealed with AES-256-GCM as soon as they are saved, and never shown again, not even to the owner. Leave the field empty when you edit to keep the saved one. |
| Can Plotwork reach my private network? | No. Plotwork sends only to HTTPS addresses on public names. |
1. Add a destination
- Open the organisation in the portal and choose the Integrations tab.
- Choose the tool, give the destination a name, and choose what it receives: the audit log, the drift results, or both.
- Fill in the tool's fields from the section below, then save.
- Press Send test event. Plotwork sends one event of type
testat once and says whether the tool took it, or why not, in one sentence such as "Datadog answered HTTP 403."
Each destination then shows, in plain sentences, when it last took a batch, its last error, how many events wait to be sent, how many were given up on, and when it tries next.
2. Datadog
Audit events and drift runs go to the Logs intake, https://http-intake.logs.<site>/api/v2/logs. Drift runs also go to the metrics API, https://api.<site>/api/v2/series.
| Field | What to enter |
|---|---|
| Site | The site your organisation signs in to, from the table below |
| API key | An API key from Organization Settings, API Keys. Not an application key. It is sent as the DD-API-KEY header. |
| Site | Domain |
|---|---|
| US1 | datadoghq.com |
| US3 | us3.datadoghq.com |
| US5 | us5.datadoghq.com |
| EU1 | datadoghq.eu |
| AP1 | ap1.datadoghq.com |
US1-FED (gov) | ddog-gov.com |
Each log has ddsource and service set to plotwork, a plotwork_type tag (audit_event, drift_run or test), a one-line summary as its message, and the whole event under the plotwork attribute.
For each diagram a drift run checked, Plotwork sends two gauges tagged repo:<repository> and source:<name>:
| Metric | Value |
|---|---|
plotwork.drift.stale | 1 when the diagram drifted from its files or could not be made, 0 when it is in step or was written |
plotwork.drift.checked | 1 |
A point carries the time it is sent, because Datadog refuses points more than an hour old and a retried batch can be older.
3. Dynatrace
Audit events and drift runs go to Log Monitoring API v2, https://<environment id>.live.dynatrace.com/api/v2/logs/ingest. Drift runs also go to Metrics API v2, /api/v2/metrics/ingest, as line protocol.
| Field | What to enter |
|---|---|
| Environment id | The first part of your environment's address, such as abc12345 in abc12345.live.dynatrace.com |
| Access token | A token with the Ingest logs (logs.ingest) and Ingest metrics (metrics.ingest) scopes. It is sent as Authorization: Api-Token <token>. |
Each log record has the event as JSON in content, its time in timestamp, log.source set to plotwork, and the attributes plotwork.type, plotwork.org_id, and plotwork.action or plotwork.repository. The drift metrics are plotwork.drift.stale and plotwork.drift.checked, gauges with the dimensions repo and source, as for Datadog, stamped by Dynatrace when they arrive.
4. Splunk
Events go to the HTTP Event Collector.
| Field | What to enter |
|---|---|
| Address | The collector's event endpoint, such as https://http-inputs-<stack>.splunkcloud.com/services/collector/event on Splunk Cloud, or https://splunk.example.com:8088/services/collector/event on a server you run. HTTPS on port 443 or 8088. |
| Index | The index to write to, or empty for the token's default |
| Token | An HTTP Event Collector token. It is sent as Authorization: Splunk <token>. Turn indexer acknowledgement off for this token. |
Each event has host set to plotwork.dev, source set to plotwork, a sourcetype of plotwork:audit_event, plotwork:drift_run or plotwork:test, its time in seconds, and the whole event as event.
5. Microsoft Sentinel
Events go to a Log Analytics workspace through the Azure Monitor Logs Ingestion API, with a token Plotwork asks Microsoft Entra ID for with the client credentials you give. Plotwork keeps the token in memory until five minutes before it expires, and never stores it.
Set it up in Azure once:
- In Microsoft Entra ID, register an application and add a client secret. Note the tenant id, the application (client) id and the secret's value.
- In the workspace that Sentinel uses, create a custom table from a data collection rule (Tables, Create, New custom log (DCR-based)), named for example
PlotworkAudit_CL, with the columns below. Azure creates the rule and its stream,Custom-PlotworkAudit_CL. - Give the application the Monitoring Metrics Publisher role on the data collection rule.
- From the rule's JSON view, copy its
immutableId. From its data collection endpoint (or the rule's own logs ingestion endpoint), copy the logs ingestion address.
| Column | Type | Holds |
|---|---|---|
TimeGenerated | datetime | When the event happened |
RecordType | string | audit_event, drift_run or test |
OrgId | string | The organisation's id |
Summary | string | A one-line summary, such as orgs.member.role by ada@acme.com on dev@acme.com |
Data | dynamic | The whole event |
| Field | What to enter |
|---|---|
| Tenant | The tenant id or its primary domain |
| Client id | The application (client) id |
| Client secret | The secret's value |
| Endpoint | The logs ingestion address, on *.ingest.monitor.azure.com |
| Rule id | The rule's immutable id, dcr- and 32 hexadecimal characters |
| Stream | The stream, such as Custom-PlotworkAudit_CL |
6. A webhook
Plotwork posts each batch to your HTTPS address as a JSON array of events.
| Header | Value |
|---|---|
Content-Type | application/json |
Plotwork-Signature | t=<unix seconds>,v1=<hex>: HMAC-SHA256 under your signing secret of the timestamp, a full stop, and the raw body |
Plotwork-Delivery | The id of the batch's first event, so a retry of the same batch carries the same value |
User-Agent | Plotwork-Delivery/1 and a link to this page |
| Field | What to enter |
|---|---|
| Address | Your endpoint. HTTPS on port 443, on a public name. |
| Signing secret | At least 32 characters. Generate in the portal fills in a random one; copy it to your receiver before you save, because it is not shown again. |
Answer with a 2xx status within 10 seconds. Any other status, a redirect, or no answer is a failure, and the batch is sent again later. Check the signature over the body exactly as it arrived, before parsing it, and refuse a timestamp more than five minutes from your clock, so a captured request cannot be replayed. An event can arrive twice, so use its id to skip one you have seen.
In Node:
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyPlotworkSignature(secret, header, rawBody, toleranceSeconds = 300) { const parts = Object.fromEntries(header.split(',').map((part) => part.trim().split('=', 2))) const timestamp = Number(parts.t) if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex') const given = String(parts.v1 ?? '') return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected))}In Python:
import hashlibimport hmacimport time
def verify_plotwork_signature(secret: str, header: str, raw_body: bytes, tolerance_seconds: int = 300) -> bool: parts = dict(part.strip().split("=", 1) for part in header.split(",") if "=" in part) try: timestamp = int(parts.get("t", "")) except ValueError: return False if abs(time.time() - timestamp) > tolerance_seconds: return False expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", ""))7. What is sent
Every event has a type, a version (1), an id, the organisation's orgId, and at, an ISO 8601 time in UTC.
| Type | Fields |
|---|---|
audit_event | The fields of plotwork.audit-event.json (opens in a new tab), the shape the audit log's export uses: action, actor (kind, id, name), target (type, id, name), metadata, ip (the network only), userAgent and requestId. The audit log guide lists every action. |
drift_run | What the GitHub Action reported: repository, ref, sha, event, runUrl, pullRequestUrl; counts with sources, inStep, drifted, written, failed and errors; and sources, each with name, out, status, added, removed, changed, errors and owners. The drift dashboard guide explains each. |
test | message, from Send test event |
Nothing else is sent: never a diagram's source, a file's content, the names of what changed, a key's value or a token.
8. How delivery works
| Question | Answer |
|---|---|
| When is an event sent? | About five seconds after the first event of a burst, so a burst goes as one batch. |
| How big is a batch? | Up to 100 events and 512 KB. Up to 10 batches go out at a time, then the rest a second later. |
| In what order? | Oldest first, for each destination. |
| What counts as a failure? | Any answer that is not 2xx, a redirect, no answer within 10 seconds, or an address that is not public HTTPS. The events stay in the queue. |
| How often is a failure tried again? | After 30 seconds, then 1, 2, 4 and 8 minutes and so on, up to once an hour, each a fifth earlier or later at random. |
| When does Plotwork give up? | An event still unsent a day after it happened is dropped and counted as given up on. So is the oldest event when 10,000 wait for one destination. |
| Can an event arrive twice? | Yes. When a tool takes a batch but its answer is lost, the batch is sent again. Use each event's id. |
| What does the status say when something fails? | One sentence of Plotwork's own, such as "Splunk answered HTTP 401." or "The webhook did not answer within 10 seconds." Never what your tool answered. |
| What happens when I remove a destination? | Its sealed secret is deleted with it, and so is everything waiting for it. |
| What does "Paused by Plotwork support" mean? | Plotwork support stopped sending to the destination, usually because it kept failing. Events keep waiting for it and are given up on after a day, as when it fails. status.pausedAt says when. Ask support why, or to resume it; nothing you set up is changed. |
| What can Plotwork support see? | Each destination's kind, name, host and delivery status, to answer a question about it. Never its secret, its full address or the events. |
9. The API
The Integrations tab uses these routes. They take the portal's session, the owner's or an admin's, and are not in the OpenAPI reference.
| Route | Does |
|---|---|
GET /orgs/{org}/integrations | Lists the destinations: the fields that are not secret, secretSet in place of the secret, and each one's status, with available saying whether the plan sends |
POST /orgs/{org}/integrations | Adds one. The body has type, name, streams (audit, drift, or both), the type's fields and its secret field (apiKey, token, clientSecret or signingSecret). |
PUT /orgs/{org}/integrations/{id} | Changes one. The type cannot change; leave the secret out to keep the saved one. |
DELETE /orgs/{org}/integrations/{id} | Removes one and what waits for it |
POST /orgs/{org}/integrations/{id}/test | Sends a test event now and answers { "ok": true } or { "ok": false, "error": "<one sentence>" } |
| Error code | Status | Means |
|---|---|---|
integrations.invalid | 400 | A field is wrong. details.reason is address (not a public HTTPS address on an allowed port), secret_required, field or type_change, and details.field names the field. |
plan.siem_delivery_not_included | 403 | The organisation's plan does not send to its tools |
orgs.forbidden | 403 | Only the owner and the admins can set it up |
integrations.limit | 409 | The organisation has five destinations already |
integrations.unavailable | 503 | Delivery is not set up on this server |
Adding, changing and removing a destination is recorded in the audit log as orgs.integration.create, orgs.integration.update and orgs.integration.delete, without the secret.
See also
| Page | Why |
|---|---|
| Audit log | Every action, the export, and polling it with a key |
| The drift dashboard | What a drift run reports |
| Single sign-on | The sign-in events the log records |
| Plans and billing | What Enterprise includes |
| Privacy policy | What is stored to send, and for how long |