Use the Sentry SDK with ZipLogger
Your app reports errors through a Sentry SDK. You want those errors next to your logs and traces, but you do not want to rewrite Sentry.init, every captureException call and your tracing setup to get there.
You do not have to. ZipLogger accepts what Sentry SDKs send, so you change the DSN and nothing else. This post shows where the DSN comes from, setups for Node.js, the browser and Python, what each kind of Sentry data becomes in ZipLogger, and what is dropped.
The Node.js and Python parts were checked with the real SDKs: @sentry/node 11.4.0 on Node.js 22 and sentry-sdk 2.71.0 on Python 3.12. Each one sent an error, two messages, a traced request, cron check-ins and structured logs. We saved the requests exactly as the SDKs sent them and replayed them through ZipLogger's receiver. The records shown below are what it stored. The browser setup was not run in a browser.
Get a DSN
In ZipLogger, go to Settings → API keys. Give the key a name, pick the environment it should send to, leave Can read data unticked, and click Create key. Under the new key you get the line "Moving from Sentry? Use this as the SDK's DSN:" with the DSN and a copy button. It looks like this:
https://[email protected]/1
A few things about it:
- It is shown once, together with the key. Copy it then.
- Only an ingest-only key gets one. A DSN ends up in browser bundles and config files, so it should only be able to send. Tick Can read data and no DSN is shown.
- The part before
@is your key, hex encoded. Sentry's JavaScript SDK only accepts letters, digits and underscores in that part, and a ZipLogger key can contain-. Every DSN key starts with7a6b5f, which iszk_in hex. - The host is the address you have ZipLogger open at when you create the key.
- The
/1at the end is a project id that ZipLogger ignores. Sentry SDKs refuse a DSN without a numeric one. The key already says which workspace and which environment the data belongs to.
Node.js
// instrument.js: load this before the rest of your app
const Sentry = require('@sentry/node')
Sentry.init({
dsn: process.env.SENTRY_DSN,
release: `checkout-api@${process.env.GIT_SHA}`,
environment: 'production',
tracesSampleRate: 0.2,
enableLogs: true,
})
Your existing Sentry.captureException(err), Sentry.captureMessage(...), Sentry.setTag(...), Sentry.setUser(...), Sentry.startSpan(...) and Sentry.withMonitor(...) calls stay as they are. enableLogs turns on Sentry.logger. Leave it out if you do not use it.
@sentry/node 11.4.0 does not use a header for the key. It adds a sentry_key query parameter to each request, and it sends the body uncompressed, without a Content-Type. ZipLogger reads the key from the query string.
Browser
import * as Sentry from '@sentry/browser'
Sentry.init({
dsn: 'https://[email protected]/1',
release: `storefront@${import.meta.env.VITE_COMMIT_SHA}`,
environment: 'production',
integrations: [Sentry.browserTracingIntegration()],
tracesSampleRate: 0.1,
})
The browser SDK puts the key in a sentry_key query parameter and posts the body as text/plain, so the browser sends no CORS preflight. ZipLogger answers with Access-Control-Allow-Origin: *. If your site has a Content Security Policy, add the DSN's host to connect-src.
If you use the SDK's tunnel option, your tunnel forwards the envelope as it is. ZipLogger then reads the key from the dsn field in the envelope header, so the tunnel only has to post it to the DSN's host at /api/1/envelope/.
Python
import os
import sentry_sdk
sentry_sdk.init(
dsn=os.environ["SENTRY_DSN"],
release=f"checkout-api@{os.environ['GIT_SHA']}",
environment="production",
traces_sample_rate=0.2,
enable_logs=True,
)
enable_logs=True turns on sentry_sdk.logger. The Python SDK sends the key in the X-Sentry-Auth header and compresses each request with gzip, or with brotli when the brotli package is installed. ZipLogger reads both, and deflate too.
Set release to service@commit
Sentry uses one project per service. ZipLogger has no projects: every service in an environment sends to the same place, so the service name and the commit come from each event.
The service is taken from a service tag if there is one, then from the part of release before @, then from the platform (python, javascript, node). Cron check-ins are the exception: they are always stored under the service cron.
The commit is taken from a commit tag (commit_sha, git_sha and git.commit also work), then from a release that is a git SHA or ends in one:
| release | Service | Commit |
|---|---|---|
checkout@3f9a2c1 | checkout | 3f9a2c1 |
[email protected]+3f9a2c1 | checkout | 3f9a2c1 |
[email protected] | checkout | none |
20260929 | the platform | none (all digits is read as a build number) |
With a commit on the error, ZipLogger can point at the change that caused it. The find the commit that broke production post explains how.
What is kept and what is dropped
A Sentry SDK sends an envelope that can hold several items. Each item type goes to the part of ZipLogger that already handles that kind of data.
| What the SDK sends | In ZipLogger |
|---|---|
Errors (captureException, uncaught exceptions) | Kept. One log line per event, with the stack trace, grouped into an Issue |
Messages (captureMessage) | Kept. One log line at the event's level, which is info unless you pass one |
Structured logs (Sentry.logger) | Kept. One log line per record, grouped by its message template when it has one |
| Cron check-ins | Kept. One log line per check-in, at error when the run failed. Also reported to the heartbeat with the same slug |
| Transactions and spans | Kept. Spans on the Traces page |
Tags, user id, release, environment, extra | Kept, as searchable fields |
| Breadcrumbs | Kept, the newest 100, shown on the Issue page. URLs lose their query string |
| Local variables of each stack frame (the Python SDK sends them by default) | Dropped |
| Request headers | A short allow-list is kept (user agent, referer, content type, host, origin and a few more). Cookies, authorization and any other header are dropped |
| Request cookies and body | Dropped. The request URL is kept without its query string |
| The user's IP address | Dropped |
| Sessions | Kept, as release health: crash-free sessions and users per release under Issues → Releases |
| Attachments, including minidumps | Dropped |
Profiles (profile, profile_chunk) | Kept, as wall-time continuous profiling flame graphs. A transaction profile links to its trace |
| Session replays | Dropped |
| User feedback | Dropped |
| Client reports | Dropped |
Dropped items still get a 200, so the SDK does not retry them or log errors. They are not billed. The response body counts what was stored under accepted and lists the item types it left out under ignored.
Some details from the runs:
- Messages. Both SDKs send
captureMessageatinfoby default.Sentry.captureMessage('...', 'warning')andcapture_message("...", level="warning")are stored atwarn.@sentry/nodealso attaches the stack of the line that calledcaptureMessage, and ZipLogger keeps it. The Python SDK sends no stack with a message unless you turn onattach_stacktrace. - Log templates. In Node.js, a record has a template when you build its message with the
Sentry.logger.fmttagged template. The run sentorder %s placed for %s items. In Python,sentry_sdk.logger.info("order {order_id} placed", order_id=id)sendsorder {order_id} placedas the template. Attributes you pass, such as{ orderTotal: 99.5 }in Node.js orattributes={"rate": 0.4}in Python, become fields on the line. - Check-ins.
Sentry.withMonitorin Node.js andwith sentry_sdk.crons.monitor(monitor_slug=...)in Python send two check-ins per run:in_progresswhen it starts, thenokorerror. That is two log lines per run, both under the servicecron. A message reads likeCron monitor nightly-report: error. When a heartbeat has the same Sentry monitor slug, the check-ins also report its runs, so a missed or failed run opens an incident. A check-in with amonitor_configcreates that heartbeat the first time.
The user's email and username are stored only if the SDK sends them, and Sentry SDKs only do that when you turn on sendDefaultPii (send_default_pii in Python).
What an error looks like
This is the code from the Python run. It raises a KeyError, turns it into a ValueError, and reports it:
sentry_sdk.set_user({"id": "42"})
sentry_sdk.set_tag("customer.tier", "gold")
sentry_sdk.set_extra("cart", {"items": 3})
def lookup(items, sku):
return items[sku]
def price(sku):
try:
return lookup({}, sku)
except KeyError as e:
raise ValueError("unknown item") from e
try:
price("sku-1")
except Exception as e:
sentry_sdk.capture_exception(e)
sentry_sdk.init had release="checkout-api@3f9a2c1", environment="production" and server_name="web-1". This is the record ZipLogger stored for it, without the timestamp:
{
"severity": "error",
"message": "ValueError: unknown item",
"source": "checkout-api",
"release": "checkout-api@3f9a2c1",
"commitSha": "3f9a2c1",
"stackTrace": "Traceback (most recent call last):\n File \"app.py\", line 31, in price\n return lookup({}, sku)\n File \"app.py\", line 26, in lookup\n return items[sku]\nKeyError: 'sku-1'\n\nThe above exception was the direct cause of the following exception:\n\nTraceback (most recent call last):\n File \"app.py\", line 37, in <module>\n price(\"sku-1\")\n File \"app.py\", line 33, in price\n raise ValueError(\"unknown item\") from e\nValueError: unknown item",
"fields": {
"exceptionType": "ValueError",
"exceptionMessage": "unknown item",
"exceptionHandled": true,
"environment": "production",
"machineName": "web-1",
"platform": "python",
"sdk": "sentry.python/2.71.0",
"traceId": "fb483ae4606942f49e411427e044630b",
"spanId": "9130be2a5957de1d",
"runtimeName": "CPython 3.12.10",
"userId": "42",
"tag.customer_tier": "gold",
"sentryEventId": "e8bf62a5691e4369bdcdbbbb6f95d7f9",
"extra.cart": "{\"items\":3}",
"extra.sys_argv": "[\"app.py\",\"48772\"]"
}
}
Some things to notice:
- The stack trace is text in your language's own format. Python gets a traceback with the source line of each frame. The same error from
@sentry/nodeis stored as a V8-style trace, innermost frame first:Error: unknown item sku-1, then lines likeat lookup (C:\srv\checkout-api\app.js:21:30)(that run was on Windows). Node.js frames carry the full path of the file on the machine that ran it, and Node's ownnode:internal/...frames are kept too. .NET and Java get their usualat ...lines. Browser frames likehttps://shop.example.com/static/js/main.jsandwebpack://shop/./src/a.tskeep only their path. ZipLogger reads file and line from this text to blame commits, and skips frames whose file has no extension, such asnode:internal/modules/cjs/loader. exceptionHandledistruebecause the code caught the error and reported it itself.- The local variables are gone. The Python SDK sent the value of
skuanditemswith each frame. ZipLogger does not store them. - Tag names lose their dots.
customer.tierbecomestag.customer_tier. A dotted name would be stored as a nested object and could clash with a plain value of the same name. extravalues are stored as text underextra.<name>, so a value that is a number in one event and an object in the next still fits. The Python SDK adds the process's command line as thesys.argvextra, which is stored asextra.sys_argv.- No query strings. When an event has a request URL, which web framework integrations add, it is stored as
requestUrlwithout its query string. Tokens in URLs stay out of your logs.
In the app, errors land on the Search page with your other logs. Errors that carry an exception are also grouped on the Issues page. On Search, expand a row to see its JSON fields, the Log fields and the Stack trace. Because the error carries traceId, the row also has View trace (full request waterfall), which opens the transaction the error happened in. Investigate shows everything connected to the line. One bug can produce thousands of lines. The Issues page shows that bug once, with a count and the users affected (how Issues work). For error lines without an exception, switch Search to Patterns to see each kind as one row with a count. The pattern clustering post explains that grouping.
Transactions and spans
The two SDKs send traces in different shapes, and both end up as the same spans.
- Python sends a transaction. It becomes a root span, named after the transaction, plus one span per child span.
@sentry/node11 sends no transaction. It sends the finished spans of a request together as onespanitem, and the span with no parent becomes the root.
Both runs wrapped a db.query span named SELECT cart in an http.server span named POST /checkout. In both, ZipLogger stored POST /checkout as a server span with SELECT cart as a client span under it, in the service checkout-api.
Attributes get the names OpenTelemetry uses, so a service on a Sentry SDK and a service on OpenTelemetry can be filtered the same way:
environmentbecomesdeployment.environmentandreleasebecomesservice.version- the span's
opis kept assentry.op - on a Python transaction, tags become
tag.<name>and the user id becomesenduser.id - spans from
@sentry/nodecarry no tags, and their user id staysuser.id - old HTTP span keys move to
http.request.method,url.fullandhttp.response.status_code - measurements such as web vitals become
measurement.<name>
A span status of ok is ok. cancelled and unknown are left unset. Anything else counts as an error.
ZipLogger does not sample on its side. If you send more than you need, lower tracesSampleRate in the SDK.
The environment comes from the key
A ZipLogger workspace can have several environments, such as Production and Staging. Each ingest key belongs to one environment for life, and that key alone decides where the data goes. The environment you set in Sentry.init has no say in it.
The SDK's environment is still stored, as the environment field on log lines and deployment.environment on spans, so you can filter by it. It just does not move data between ZipLogger environments. In the receiver's tests, an error with environment set to production is sent with a key from a Staging environment. It is stored in Staging, and its environment field still reads production.
So give each deployment its own DSN. Create one key per environment in Settings → API keys by picking the environment before you click Create key. Settings → Environments can also make an ingest key when you add an environment, but it shows the raw key, not a DSN.
Retries, limits and errors
- A resent error or message is stored once. The envelope's
event_idis the idempotency key for its log lines. If an SDK's offline cache sends the same event again, it gets a 200 and nothing is stored or billed twice. Spans are stored under their trace id plus span id, so a resent span is not stored twice. Structured logs from both SDKs, and check-ins from@sentry/node, arrive without anevent_id, so a resent batch of those is stored again. - Request size. In production these routes accept up to 20 MB per request, and up to 64 MB once decompressed. Over that, the answer is 413 and the SDK drops the envelope.
- Quota. When your plan's allowance or daily ceiling is used up, the answer is 429 with
Retry-Afterand anX-Sentry-Rate-Limitsheader. Sentry SDKs read it and hold back every kind of data for that long. Errors, logs and spans draw on the same plan allowance. - Wrong key. A missing key, an unknown key, a key without ingest access or a key of an archived environment is a 401, and the SDK drops the event.
The legacy /api/<project>/store/ endpoint that old SDKs use is accepted too.
What it does not do
- Issues work differently. Errors with an exception are grouped into ZipLogger Issues, which you assign, resolve and ignore. Your
fingerprintis honored, but the default grouping is ZipLogger's own, so expect different groups than in Sentry. Ownership rules, merging and tracker integrations do not carry over. - No sentry-cli uploads. Upload source maps to ZipLogger's own endpoint from CI, under the same
releasestring your SDK sends (how). Debug IDs are not used for matching. Mapped frames are shown on the Issue page. Commit blame still reads the frames as received, so it cannot blame minified code. - No replays, attachments or user feedback. See the table above. Changing the DSN does not import Sentry replay recordings, attachments or feedback, and past events stay in Sentry. Breadcrumbs come only from Sentry SDK events; ZipLogger's own SDKs do not send them.
- Check-ins need a heartbeat to page. A check-in for a slug that no heartbeat has, and without a
monitor_config, is only a log line. Sentry'sfailure_issue_thresholdandrecovery_thresholdare not used: a heartbeat opens an incident on the first missed or failed run. See cron job monitoring. - Nothing is imported. Past events, alert rules and ownership rules stay in Sentry.
- One DSN per SDK. A Sentry SDK sends to one DSN, so to compare the two, move one service at a time.
Next step
The migrating from Sentry page compares the two tools and lists what carries over. The short setup is in the docs under Sentry SDKs. To try it, create a free workspace, make an ingest key, and paste its DSN into Sentry.init.