Collect Sentry logs
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)
- Sign in to Sentry.
- Find your Organization slug:
- Go to Settings > Organization > Settings > Organization ID (the slug appears next to the org name).
- 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>
.
- (If self-hosted): Note your base URL (for example,
https://<your-domain>
); otherwise usehttps://sentry.io
.
Configure AWS S3 bucket and IAM for Google SecOps
- Create Amazon S3 bucket following this user guide: Creating a bucket
- Save bucket Name and Region for future reference (for example,
sentry-logs
). - Create a User following this user guide: Creating an IAM user.
- Select the created User.
- Select Security credentials tab.
- Click Create Access Key in section Access Keys.
- Select Third-party service as Use case.
- Click Next.
- Optional: Add a description tag.
- Click Create access key.
- Click Download CSV file to save the Access Key and Secret Access Key for future reference.
- Click Done.
- Select Permissions tab.
- Click Add permissions in section Permissions policies.
- Select Add permissions.
- Select Attach policies directly.
- Search for AmazonS3FullAccess policy.
- Select the policy.
- Click Next.
- Click Add permissions.
Configure the IAM policy and role for S3 uploads
- In the AWS console, go to IAM > Policies.
- Click Create policy > JSON tab.
- Copy and paste the following policy.
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" } ] }
Click Next > Create policy.
Go to IAM > Roles > Create role > AWS service > Lambda.
Attach the newly created policy.
Name the role
WriteSentryToS3Role
and click Create role.
Create the Lambda function
- In the AWS Console, go to Lambda > Functions > Create function.
- Click Author from scratch.
Provide the following configuration details:
Setting Value Name sentry_to_s3
Runtime Python 3.13 Architecture x86_64 Execution role WriteSentryToS3Role
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())
Go to Configuration > Environment variables.
Click Edit > Add new environment variable.
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. After the function is created, stay on its page (or open Lambda > Functions > your-function).
Select the Configuration tab.
In the General configuration panel click Edit.
Change Timeout to 5 minutes (300 seconds) and click Save.
Create an EventBridge schedule
- Go to Amazon EventBridge > Scheduler > Create schedule.
- Provide the following configuration details:
- Recurring schedule: Rate (
1 hour
). - Target: Your Lambda function
sentry_to_s3
. - Name:
sentry-1h
.
- Recurring schedule: Rate (
- Click Create schedule.
(Optional) Create read-only IAM user & keys for Google SecOps
- In the AWS Console, go to IAM > Users.
- Click Add users.
- Provide the following configuration details:
- User: Enter
secops-reader
. - Access type: Select Access key — Programmatic access.
- User: Enter
- Click Create user.
- Attach minimal read policy (custom): Users > secops-reader > Permissions > Add permissions > Attach policies directly > Create policy.
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" } ] }
Name =
secops-reader-policy
.Click Create policy > search/select > Next > Add permissions.
Create access key for
secops-reader
: Security credentials > Access keys.Click Create access key.
Download the
.CSV
. (You'll paste these values into the feed).
Configure a feed in Google SecOps to ingest Sentry logs
- Go to SIEM Settings > Feeds.
- Click + Add New Feed.
- In the Feed name field, enter a name for the feed (for example,
Sentry Logs
). - Select Amazon S3 V2 as the Source type.
- Select Sentry as the Log type.
- Click Next.
- 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.
- S3 URI:
- Click Next.
- 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.