> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stackone.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Observability & Log Sync

> Export action logs to Grafana, Datadog, or your internal monitoring systems

StackOne records a log for every action run. You can integrate these logs with your observability stack for centralized monitoring, alerting, and debugging.

<Info>
  This guide is for **platform builders** who want to integrate StackOne action logs with their existing monitoring infrastructure. For debugging individual failed requests, see [Request Log Debugging](/features/observability/request-log-debugging). To build embedded log dashboards, see [Request Log Dashboards](/features/observability/request-log-dashboards).
</Info>

## Integration approaches

Choose the approach that fits your needs:

| Approach | Best For | Complexity |
| - | - | - |
| **Direct polling** (Grafana Infinity) | Simple dashboards, ad-hoc queries | Low |
| **Push model** (Sync worker) | High-volume, real-time alerts, data transformation | Medium |

## Query request logs

Logs are read with [`POST /logs`](/platform/api-reference/logs/list-logs). Filters, ordering and [pagination](/platform-api/request-parameters/pagination) all travel in the JSON body rather than the query string.

One row is one action run, and that includes MCP tool calls.

<Tabs>
  <Tab title="cURL">
    ```bash theme={null}
    curl -X POST "https://api.stackone.com/logs" \
      -u "$STACKONE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{
        "page": 1,
        "page_size": 100,
        "filters": {
          "log_type": ["action"],
          "order_by": "event_time",
          "order_direction": "desc"
        }
      }'
    ```
  </Tab>

  <Tab title="TypeScript (Node)">
    ```typescript theme={null}
    const auth = Buffer.from(`${process.env.STACKONE_API_KEY}:`).toString("base64");

    const response = await fetch("https://api.stackone.com/logs", {
      method: "POST",
      headers: {
        Authorization: `Basic ${auth}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        page: 1,
        page_size: 100,
        filters: {
          log_type: ["action"],
          order_by: "event_time",
          order_direction: "desc",
        },
      }),
    });

    const { data: logs, total } = await response.json();
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    import base64
    import os
    import requests

    api_key = os.environ["STACKONE_API_KEY"]
    headers = {
        "Authorization": f"Basic {base64.b64encode(f'{api_key}:'.encode()).decode()}",
        "Content-Type": "application/json",
    }

    response = requests.post(
        "https://api.stackone.com/logs",
        json={
            "page": 1,
            "page_size": 100,
            "filters": {
                "log_type": ["action"],
                "order_by": "event_time",
                "order_direction": "desc",
            },
        },
        headers=headers,
    )

    body = response.json()
    logs = body["data"]
    total = body["total"]
    ```
  </Tab>
</Tabs>

### Key fields for monitoring

| Field | Description | Use Case |
| - | - | - |
| `status_code` | HTTP response status | Error rate alerts |
| `success` | Boolean success flag | Quick filtering |
| `duration_ms` | Action run latency in milliseconds | Performance monitoring |
| `connector_key` | Connector the action ran against | Connector dashboards |
| `account_id` | Linked account | Customer-level debugging |
| `action_id` | Action executed | Usage analytics |
| `action_type` | For example `sync` | Separating scheduled work from live calls |
| `category` | `action` for action RPC and MCP runs, the unified vertical for runs that came in on a unified path | Separating agent traffic from unified API traffic |
| `mode` | `production` or `test` | Excluding test traffic |
| `risk_level` | [Defender's](/secure/defender) risk assessment, present only on projects with Defender enabled | Security alerting downstream |

## Filter logs

Filters go in the `filters` object. Most take an **array** of strings, so a single value still needs brackets. The rest are scalars, and [List Logs](/platform/api-reference/logs/list-logs) gives the type of each.

<Tabs>
  <Tab title="cURL">
    ```bash theme={null}
    # Errors from two connectors, inside a time window
    curl -X POST "https://api.stackone.com/logs" \
      -u "$STACKONE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{
        "page": 1,
        "page_size": 100,
        "filters": {
          "log_type": ["action"],
          "start_time": "2026-01-15T09:00:00.000Z",
          "end_time": "2026-01-15T17:00:00.000Z",
          "status_code": ["400", "500", "502", "503"],
          "connector_key": ["bamboohr", "workday"],
          "order_by": "event_time",
          "order_direction": "desc"
        }
      }'

    # Everything for one account
    curl -X POST "https://api.stackone.com/logs" \
      -u "$STACKONE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{"filters": {"log_type": ["action"], "account_secure_id": ["45355976281015164504"]}}'

    # Failed runs of one action
    curl -X POST "https://api.stackone.com/logs" \
      -u "$STACKONE_API_KEY:" \
      -H "Content-Type: application/json" \
      -d '{"filters": {"log_type": ["action"], "action_id": ["bamboohr_list_employees"], "success": false}}'
    ```
  </Tab>

  <Tab title="TypeScript (Node)">
    ```typescript theme={null}
    const auth = Buffer.from(`${process.env.STACKONE_API_KEY}:`).toString("base64");

    const response = await fetch("https://api.stackone.com/logs", {
      method: "POST",
      headers: {
        Authorization: `Basic ${auth}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        page: 1,
        page_size: 100,
        filters: {
          log_type: ["action"],
          start_time: "2026-01-15T09:00:00.000Z",
          end_time: "2026-01-15T17:00:00.000Z",
          status_code: ["400", "401", "500", "502", "503"],
          connector_key: ["bamboohr", "workday"],
          account_secure_id: ["45355976281015164504"],
          order_by: "event_time",
          order_direction: "desc",
        },
      }),
    });
    ```
  </Tab>
</Tabs>

<Tip>
  For pre-aggregated counts rather than raw rows, [Logs Stats Aggregate](/platform/api-reference/logs/get-logs-stats-aggregate) does the grouping server-side and returns far less data.
</Tip>

## Grafana direct polling

The simplest approach is to let Grafana poll the StackOne API directly using the [Infinity data source](https://grafana.com/grafana/plugins/yesoreyeram-infinity-datasource/).

### Setup

1. Install the Infinity plugin in Grafana
2. Add a new Infinity data source with these settings:

| Setting | Value |
| - | - |
| **URL** | `https://api.stackone.com` |
| **Auth** | Basic Auth |
| **User** | Your StackOne API key (e.g., `v1.eu1.xxxxx`) |
| **Password** | Leave empty |
| **Allowed hosts** | `https://api.stackone.com` |

### Example query

Because `POST /logs` reads its filters from the body, the Infinity panel needs to send a POST with a JSON body rather than a URL with query parameters:

```yaml theme={null}
Type: JSON
Source: URL
Method: POST
URL: /logs
Headers: Content-Type = application/json
Rows/Root: data
Parser: Backend
Body Type: Raw
Body Content Type: application/json
Body:
  {
    "page": 1,
    "page_size": 100,
    "filters": {
      "log_type": ["action"],
      "start_time": "${__from:date:iso}",
      "end_time": "${__to:date:iso}",
      "status_code": ["400", "500", "502", "503"],
      "order_by": "event_time",
      "order_direction": "desc"
    }
  }
```

Set **Rows/Root** to `data` so Infinity reads the array rather than the pagination envelope.

<Warning>
  A panel issues one request, so it renders a single page. Any range holding more rows than
  `page_size` gives a partial set with nothing in the panel to say so. Compare `total` against the
  row count, narrow the range, or use the sync worker below when the set has to be complete.
</Warning>

### Dashboard variables

Create variables for dynamic filtering, and interpolate them into the body as JSON arrays:

| Variable | Query | Body field |
| - | - | - |
| `connector` | Static values: `bamboohr`, `workday`, `greenhouse`, etc. | `filters.connector_key` |
| `account` | Use [`GET /v2/accounts`](/platform/api-reference/v2/accounts/list-accounts) to fetch linked accounts | `filters.account_secure_id` |

With multi-value variables, the `${connector:json}` format option renders the selection as a JSON array, which is what the filter expects:

```json theme={null}
{ "filters": { "log_type": ["action"], "connector_key": ${connector:json}, "account_secure_id": ${account:json} } }
```

<Tip>
  This approach is best for dashboards and ad-hoc analysis. For real-time alerting or high-volume ingestion, use the sync worker approach below.
</Tip>

## Build a log sync worker

For high-volume ingestion or when you need to transform logs before storing, build a sync worker that polls the StackOne API and forwards logs to your observability platform.

<Tip>
  Popular approaches include using [Temporal](https://temporal.io/) workflows, AWS Lambda with EventBridge schedules, or simple cron jobs. The core pattern is the same: poll for new logs since your last sync, then forward to your platform.
</Tip>

### Core pattern

Page forward until you have collected `total` rows. Bound each run with both `start_time` and `end_time` so the result set cannot shift underneath the sweep, and order ascending by `event_time` so the sequence is deterministic.

A run that fails partway is retried whole, because the window is only checkpointed once it completes. Make forwarding idempotent on `log_id` so the retry does not duplicate rows your platform already holds.

<Tabs>
  <Tab title="TypeScript (Node)">
    ```typescript theme={null}
    const PAGE_SIZE = 100;

    async function fetchLogsBetween(startTime: string, endTime: string) {
      const auth = Buffer.from(`${process.env.STACKONE_API_KEY}:`).toString("base64");
      const collected = [];
      let page = 1;

      while (true) {
        const response = await fetch("https://api.stackone.com/logs", {
          method: "POST",
          headers: {
            Authorization: `Basic ${auth}`,
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            page,
            page_size: PAGE_SIZE,
            filters: {
              log_type: ["action"],
              start_time: startTime,
              end_time: endTime,
              order_by: "event_time",
              order_direction: "asc",
            },
          }),
        });

        if (!response.ok) {
          throw new Error(`StackOne logs request failed: ${response.status}`);
        }

        const { data, total } = await response.json();
        collected.push(...data);

        // Stop on total, not on a short page. The endpoint can return fewer rows
        // than you asked for while still echoing back the page_size you sent.
        if (data.length === 0 || collected.length >= total) {
          return collected;
        }
        page += 1;
      }
    }

    // Usage: sweep a closed window, forward to your platform, then store runEnd
    // as the next run's start
    const runEnd = new Date().toISOString();
    const logs = await fetchLogsBetween(lastSyncTime, runEnd);
    for (const log of logs) {
      await forwardToObservabilityPlatform(log); // Your implementation
    }
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    import base64
    import os
    from datetime import datetime, timezone

    import requests

    PAGE_SIZE = 100


    def fetch_logs_between(start_time: str, end_time: str) -> list[dict]:
        api_key = os.environ["STACKONE_API_KEY"]
        headers = {
            "Authorization": f"Basic {base64.b64encode(f'{api_key}:'.encode()).decode()}",
            "Content-Type": "application/json",
        }

        collected, page = [], 1
        while True:
            response = requests.post(
                "https://api.stackone.com/logs",
                json={
                    "page": page,
                    "page_size": PAGE_SIZE,
                    "filters": {
                        "log_type": ["action"],
                        "start_time": start_time,
                        "end_time": end_time,
                        "order_by": "event_time",
                        "order_direction": "asc",
                    },
                },
                headers=headers,
            )
            response.raise_for_status()

            body = response.json()
            rows = body["data"]
            collected.extend(rows)

            # Stop on total, not on a short page. The endpoint can return fewer
            # rows than you asked for while still echoing back the page_size sent.
            if not rows or len(collected) >= body["total"]:
                return collected
            page += 1


    # Usage: sweep a closed window, forward to your platform, then store run_end
    # as the next run's start
    run_end = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
    logs = fetch_logs_between(last_sync_time, run_end)
    for log in logs:
        forward_to_observability_platform(log)  # Your implementation
    ```
  </Tab>
</Tabs>

### Platform-specific examples

<Tabs>
  <Tab title="Grafana Loki">
    ```typescript theme={null}
    async function sendToLoki(log: TransformedLog) {
      await fetch('http://loki:3100/loki/api/v1/push', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          streams: [{
            stream: log.labels,
            values: [[`${log.timestamp}000000`, JSON.stringify(log)]]
          }]
        })
      });
    }
    ```
  </Tab>

  <Tab title="Datadog">
    ```typescript theme={null}
    async function sendToDatadog(log: DatadogLog) {
      await fetch('https://http-intake.logs.datadoghq.com/api/v2/logs', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'DD-API-KEY': process.env.DATADOG_API_KEY!
        },
        body: JSON.stringify([log])
      });
    }
    ```
  </Tab>

  <Tab title="OpenTelemetry">
    ```typescript theme={null}
    import { logs } from '@opentelemetry/api-logs';

    function sendToOtel(log: ActionLog) {
      const logger = logs.getLogger('stackone');

      logger.emit({
        // success is independent of status_code, which is null when the run failed
        // before it got a response. Check both or those failures log as INFO.
        severityNumber: log.success === false || (log.status_code ?? 0) >= 400 ? 17 : 9,
        body: `${log.connector_key} ${log.action_id}`,
        // Only action_run_id is guaranteed. Everything else is nullable, and
        // OpenTelemetry rejects null attribute values, so drop them first.
        attributes: defined({
          'http.status_code': log.status_code,
          'stackone.action_id': log.action_id,
          'stackone.action_run_id': log.action_run_id,
          'stackone.action_type': log.action_type,
          'stackone.category': log.category,
          'stackone.connector_key': log.connector_key,
          'stackone.account_id': log.account_id,
          'stackone.risk_level': log.risk_level, // Defender projects only
          'duration_ms': log.duration_ms
        })
      });
    }

    function defined(attributes: Record<string, unknown>) {
      return Object.fromEntries(
        Object.entries(attributes).filter(([, value]) => value !== null && value !== undefined)
      );
    }
    ```
  </Tab>
</Tabs>

***

## Suggested dashboards

### Key metrics to track

| Metric | Query Pattern | Alert Threshold |
| - | - | - |
| Error Rate | `status_code >= 400` | > 5% over 5 min |
| P95 Latency | `percentile(duration_ms, 95)` | > 2000ms |
| Connector Health | Group by `connector_key`, `status_code` | Any connector > 10% errors |
| Action Volume | Count by `action_id` and time bucket | Anomaly detection |

### Grafana dashboard JSON

These panels query Prometheus, so they assume you already turn action logs into metrics, for example by having the sync worker increment counters as it forwards each row. StackOne does not expose a metrics endpoint, so `stackone_action_runs_total` and `stackone_action_duration_bucket` are series you define, not ones you can scrape.

```json theme={null}
{
  "panels": [
    {
      "title": "Error Rate by Connector",
      "type": "timeseries",
      "targets": [{
        "expr": "sum(rate(stackone_action_runs_total{status_code=~\"4..|5..\"}[5m])) by (connector_key) / sum(rate(stackone_action_runs_total[5m])) by (connector_key)"
      }]
    },
    {
      "title": "P95 Latency",
      "type": "stat",
      "targets": [{
        "expr": "histogram_quantile(0.95, sum(rate(stackone_action_duration_bucket[5m])) by (le))"
      }]
    }
  ]
}
```

## Related

<CardGroup cols={2}>
  <Card title="Request Logs API" icon="code" href="/platform/api-reference/logs/list-logs">
    Full API reference for logs
  </Card>

  <Card title="Dashboard Logs" icon="scroll" href="/connect/troubleshooting">
    View logs in the StackOne dashboard
  </Card>

  <Card title="Webhooks" icon="bell" href="/connect/webhooks">
    Real-time event notifications
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.