Collect Sentry logs

Supported in:

This document explains how to ingest Sentry logs to Google Security Operations using Amazon S3. Sentry produces operational data in the form of events, issues, performance monitoring data, and error tracking information. This integration lets you send these logs to Google SecOps for analysis and monitoring, providing visibility into application errors, performance issues, and user interactions within your Sentry-monitored applications.

Before you begin

Make sure you have the following prerequisites:

  • A Google SecOps instance.
  • Privileged access to Sentry tenant (Auth Token with API scopes).
  • Privileged access to AWS (S3, Identity and Access Management (IAM), Lambda, EventBridge).

Collect Sentry prerequisites (IDs, API keys, org IDs, tokens)

  1. Sign in to Sentry.
  2. Find your Organization slug:
    • Go to Settings > Organization > Settings > Organization ID (the slug appears next to the org name).
  3. Create an Auth Token:
    • Go to Settings > Developer Settings > Personal Tokens.
    • Click Create New
    • Scopes (minimum): org:read, project:read, event:read.
    • Copy the token value (shown once). This is used as: Authorization: Bearer <token>.
  4. (If self-hosted): Note your base URL (for example, https://<your-domain>); otherwise use https://sentry.io.

Configure AWS S3 bucket and IAM for Google SecOps

  1. Create Amazon S3 bucket following this user guide: Creating a bucket
  2. Save bucket Name and Region for future reference (for example, sentry-logs).
  3. Create a User following this user guide: Creating an IAM user.
  4. Select the created User.
  5. Select Security credentials tab.
  6. Click Create Access Key in section Access Keys.
  7. Select Third-party service as Use case.
  8. Click Next.
  9. Optional: Add a description tag.
  10. Click Create access key.
  11. Click Download CSV file to save the Access Key and Secret Access Key for future reference.
  12. Click Done.
  13. Select Permissions tab.
  14. Click Add permissions in section Permissions policies.
  15. Select Add permissions.
  16. Select Attach policies directly.
  17. Search for AmazonS3FullAccess policy.
  18. Select the policy.
  19. Click Next.
  20. Click Add permissions.

Configure the IAM policy and role for S3 uploads

  1. In the AWS console, go to IAM > Policies.
  2. Click Create policy > JSON tab.
  3. Copy and paste the following policy.
  4. Policy JSON (replace sentry-logs if you entered a different bucket name):

    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Sid": "AllowPutObjects",
          "Effect": "Allow",
          "Action": "s3:PutObject",
          "Resource": "arn:aws:s3:::sentry-logs/*"
        },
        {
          "Sid": "AllowGetStateObject",
          "Effect": "Allow",
          "Action": "s3:GetObject",
          "Resource": "arn:aws:s3:::sentry-logs/sentry/events/state.json"
        }
      ]
    }
    
  5. Click Next > Create policy.

  6. Go to IAM > Roles > Create role > AWS service > Lambda.

  7. Attach the newly created policy.

  8. Name the role WriteSentryToS3Role and click Create role.

Create the Lambda function

  1. In the AWS Console, go to Lambda > Functions > Create function.
  2. Click Author from scratch.
  3. Provide the following configuration details:

    Setting Value
    Name sentry_to_s3
    Runtime Python 3.13
    Architecture x86_64
    Execution role WriteSentryToS3Role
  4. After the function is created, open the Code tab, delete the stub and paste the following code (sentry_to_s3.py).

    #!/usr/bin/env python3
    # Lambda: Pull Sentry project events (raw JSON) to S3 using Link "previous" cursor for duplicate-safe polling
    
    import os, json, time
    from urllib.request import Request, urlopen
    from urllib.parse import urlencode, urlparse, parse_qs
    import boto3
    
    ORG = os.environ["SENTRY_ORG"].strip()
    TOKEN = os.environ["SENTRY_AUTH_TOKEN"].strip()
    S3_BUCKET = os.environ["S3_BUCKET"]
    S3_PREFIX = os.environ.get("S3_PREFIX", "sentry/events/")
    STATE_KEY = os.environ.get("STATE_KEY", "sentry/events/state.json")
    BASE = os.environ.get("SENTRY_API_BASE", "https://sentry.io").rstrip("/")
    MAX_PROJECTS = int(os.environ.get("MAX_PROJECTS", "100"))
    MAX_PAGES_PER_PROJECT = int(os.environ.get("MAX_PAGES_PER_PROJECT", "5"))
    
    s3 = boto3.client("s3")
    HDRS = {"Authorization": f"Bearer {TOKEN}", "Accept": "application/json", "User-Agent": "chronicle-s3-sentry-lambda/1.0"}
    
    def _get_state() -> dict:
        try:
            obj = s3.get_object(Bucket=S3_BUCKET, Key=STATE_KEY)
            raw = obj["Body"].read()
            return json.loads(raw) if raw else {"projects": {}}
        except Exception:
            return {"projects": {}}
    
    def _put_state(state: dict):
        s3.put_object(Bucket=S3_BUCKET, Key=STATE_KEY, Body=json.dumps(state, separators=(",", ":")).encode("utf-8"))
    
    def _req(path: str, params: dict | None = None):
        url = f"{BASE}{path}"
        if params:
            url = f"{url}?{urlencode(params)}"
        req = Request(url, method="GET", headers=HDRS)
        with urlopen(req, timeout=60) as r:
            data = json.loads(r.read().decode("utf-8"))
            link = r.headers.get("Link")
            return data, link
    
    def _parse_link(link_header: str | None):
        """Return (prev_cursor, prev_has_more, next_cursor, next_has_more)."""
        if not link_header:
            return None, False, None, False
        prev_cursor, next_cursor = None, None
        prev_more, next_more = False, False
        parts = [p.strip() for p in link_header.split(",")]
        for p in parts:
            if "<" not in p or ">" not in p:
                continue
            url = p.split("<", 1)[1].split(">", 1)[0]
            rel = "previous" if 'rel="previous"' in p else ("next" if 'rel="next"' in p else None)
            has_more = 'results="true"' in p
            try:
                q = urlparse(url).query
                cur = parse_qs(q).get("cursor", [None])[0]
            except Exception:
                cur = None
            if rel == "previous":
                prev_cursor, prev_more = cur, has_more
            elif rel == "next":
                next_cursor, next_more = cur, has_more
        return prev_cursor, prev_more, next_cursor, next_more
    
    def _write_page(project_slug: str, payload: object, page_idx: int) -> str:
        ts = time.gmtime()
        key = f"{S3_PREFIX.rstrip('/')}/{time.strftime('%Y/%m/%d', ts)}/sentry-{project_slug}-{page_idx:05d}.json"
        s3.put_object(Bucket=S3_BUCKET, Key=key, Body=json.dumps(payload, separators=(",", ":")).encode("utf-8"))
        return key
    
    def list_projects(max_projects: int):
        projects, cursor = [], None
        while len(projects) < max_projects:
            params = {"cursor": cursor} if cursor else {}
            data, link = _req(f"/api/0/organizations/{ORG}/projects/", params)
            for p in data:
                slug = p.get("slug")
                if slug:
                    projects.append(slug)
                    if len(projects) >= max_projects:
                        break
            # advance pagination
            _, _, next_cursor, next_more = _parse_link(link)
            cursor = next_cursor if next_more else None
            if not next_more:
                break
        return projects
    
    def fetch_project_events(project_slug: str, start_prev_cursor: str | None):
        # If we have a stored "previous" cursor, poll forward (newer) until no more results.
        # If not (first run), fetch the latest page, then optionally follow "next" (older) for initial backfill up to the limit.
        pages = 0
        total = 0
        latest_prev_cursor_to_store = None
    
        def _one(cursor: str | None):
            nonlocal pages, total, latest_prev_cursor_to_store
            params = {"cursor": cursor} if cursor else {}
            data, link = _req(f"/api/0/projects/{ORG}/{project_slug}/events/", params)
            _write_page(project_slug, data, pages)
            total += len(data) if isinstance(data, list) else 0
            prev_c, prev_more, next_c, next_more = _parse_link(link)
            # capture the most recent "previous" cursor observed to store for the next run
            latest_prev_cursor_to_store = prev_c or latest_prev_cursor_to_store
            pages += 1
            return prev_c, prev_more, next_c, next_more
    
        if start_prev_cursor:
            # Poll new pages toward "previous" until no more
            cur = start_prev_cursor
            while pages < MAX_PAGES_PER_PROJECT:
                prev_c, prev_more, _, _ = _one(cur)
                if not prev_more:
                    break
                cur = prev_c
        else:
            # First run: start at newest, then (optionally) backfill a few older pages
            prev_c, _, next_c, next_more = _one(None)
            cur = next_c
            while next_more and pages < MAX_PAGES_PER_PROJECT:
                _, _, next_c, next_more = _one(cur)
                cur = next_c
    
        return {"project": project_slug, "pages": pages, "written": total, "store_prev_cursor": latest_prev_cursor_to_store}
    
    def lambda_handler(event=None, context=None):
        state = _get_state()
        state.setdefault("projects", {})
    
        projects = list_projects(MAX_PROJECTS)
        summary = []
        for slug in projects:
            start_prev = state["projects"].get(slug, {}).get("prev_cursor")
            res = fetch_project_events(slug, start_prev)
            if res.get("store_prev_cursor"):
                state["projects"][slug] = {"prev_cursor": res["store_prev_cursor"]}
            summary.append(res)
    
        _put_state(state)
        return {"ok": True, "projects": len(projects), "summary": summary}
    
    if __name__ == "__main__":
        print(lambda_handler())
    
  5. Go to Configuration > Environment variables.

  6. Click Edit > Add new environment variable.

  7. Enter the environment variables provided in the following table, replacing the example values with your values.

    Environment variables

    Key Example Value Description
    S3_BUCKET sentry-logs S3 bucket name where data will be stored.
    S3_PREFIX sentry/events/ Optional S3 prefix (subfolder) for objects.
    STATE_KEY sentry/events/state.json Optional state/checkpoint file key.
    SENTRY_ORG your-org-slug Sentry organization slug.
    SENTRY_AUTH_TOKEN sntrys_************************ Sentry Auth Token with org:read, project:read, event:read.
    SENTRY_API_BASE https://sentry.io Sentry API base URL (self-hosted: https://<your-domain>).
    MAX_PROJECTS 100 Maximum number of projects to process.
    MAX_PAGES_PER_PROJECT 5 Maximum pages per project per execution.
  8. After the function is created, stay on its page (or open Lambda > Functions > your-function).

  9. Select the Configuration tab.

  10. In the General configuration panel click Edit.

  11. Change Timeout to 5 minutes (300 seconds) and click Save.

Create an EventBridge schedule

  1. Go to Amazon EventBridge > Scheduler > Create schedule.
  2. Provide the following configuration details:
    • Recurring schedule: Rate (1 hour).
    • Target: Your Lambda function sentry_to_s3.
    • Name: sentry-1h.
  3. Click Create schedule.

(Optional) Create read-only IAM user & keys for Google SecOps

  1. In the AWS Console, go to IAM > Users.
  2. Click Add users.
  3. Provide the following configuration details:
    • User: Enter secops-reader.
    • Access type: Select Access key — Programmatic access.
  4. Click Create user.
  5. Attach minimal read policy (custom): Users > secops-reader > Permissions > Add permissions > Attach policies directly > Create policy.
  6. JSON:

    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Action": ["s3:GetObject"],
          "Resource": "arn:aws:s3:::sentry-logs/*"
        },
        {
          "Effect": "Allow",
          "Action": ["s3:ListBucket"],
          "Resource": "arn:aws:s3:::sentry-logs"
        }
      ]
    }
    
  7. Name = secops-reader-policy.

  8. Click Create policy > search/select > Next > Add permissions.

  9. Create access key for secops-reader: Security credentials > Access keys.

  10. Click Create access key.

  11. Download the .CSV. (You'll paste these values into the feed).

Configure a feed in Google SecOps to ingest Sentry logs

  1. Go to SIEM Settings > Feeds.
  2. Click + Add New Feed.
  3. In the Feed name field, enter a name for the feed (for example, Sentry Logs).
  4. Select Amazon S3 V2 as the Source type.
  5. Select Sentry as the Log type.
  6. Click Next.
  7. Specify values for the following input parameters:
    • S3 URI: s3://sentry-logs/sentry/events/
    • Source deletion options: Select deletion option according to your preference.
    • Maximum File Age: Include files modified in the last number of days. Default is 180 days.
    • Access Key ID: User access key with access to the S3 bucket.
    • Secret Access Key: User secret key with access to the S3 bucket.
    • Asset namespace: The asset namespace.
    • Ingestion labels: The label applied to the events from this feed.
  8. Click Next.
  9. Review your new feed configuration in the Finalize screen, and then click Submit.

Need more help? Get answers from Community members and Google SecOps professionals.