When a parser breaks at 10:06, the first question is who changed what at 10:05. Every Logit.io Logs stack keeps an audit log that answers it, with the user, the IP address and the action. This guide shows how to read and export it, how to pull it from the Dashboard API, and how to ship it with Filebeat into logstash-* so it sits on the same OpenSearch Dashboards timeline as your application logs.
Contents
- The short answer
- What the audit log records
- Find an event in the dashboard
- Export CSV or JSON
- Pull it with the API
- Ship audit events into logstash-*
- Verify it on your stack
- Use it in an incident
- Failure modes
- Trade-offs
- FAQ
- Does the audit log record searches made in OpenSearch Dashboards?
- Can I see what changed in the Logstash filter?
- Why does the export ignore my search and time filters?
- Which API key should I use for the scheduled pull?
- Can I pull the account audit log through the API?
- How long are audit events kept once shipped?
- Keep the record next to your logs
The short answer
Every Logit.io Logs stack keeps an audit log of who did what: who opened OpenSearch Dashboards, who updated the Logstash filter, who removed a user, and from which IP address. You can read it under Audit Log in the stack menu, export it as CSV or JSON, or pull it from GET /api/stacks/{stackId}/audit-log with a Dashboard API key. The dashboard view is built for recent activity, so if you need months of history for an access review or an incident, pull the events on a schedule, write them as NDJSON with a proper @timestamp, and ship them with Filebeat into logstash-* next to the rest of your logs.
We built and ran everything below: a 40-line Bash puller (curl 8.14, jq 1.7), a Filebeat 9.5.5 filestream input with the ndjson parser, and a mock that matches the API's documented response. Two of the failure modes in this post came straight out of that test run.
| Where | What you get | Best for |
|---|---|---|
| Stack Audit Log page | Search, time filter, user sort, row details | "Who changed this in the last day?" |
| Export CSV or Export JSON | A file of recent events for the stack or account, up to an export limit | One-off evidence for a ticket or an auditor |
| Audit log API | Paged JSON, 25 entries per page by default | Scripts, scheduled pulls, alerting |
Audit events in logstash-* | Same retention, search and visualisation as your application logs | Access reviews, incident timelines, long-term history |
What the audit log records
There are two audit logs. The stack audit log covers one Logs stack. The account audit log covers the whole account, including user invitations, team changes and billing pages. Both use the same page layout and the same search engine underneath.
Each event carries a time, a username, an email address, an IP address, a free-text message and a source. The source tells you whether the action came through the web dashboard or through the Dashboard API. Messages are short and literal. These are some you will see on a busy stack:
Viewed OpenSearch Dashboards,Viewed Grafana,Viewed JaegerUpdated Logstash FilterCreated Alert Rule ...,Updated Alert Rule ...,Deleted Alert Rule ...Viewed Firewall Rules,Viewed Logstash Input RulesSet TLS Min Version: ...Resized Plan from ... to ...Viewed Dashboard LibraryViewed Stack Audit LogandExported Stack Audit Logs
On the account side you get events such as Created invite token for ..., Removed User ..., Deleted Team ..., Granted Account Support Access and Revoked Account Support Access.
Two things are worth knowing early. First, viewing and exporting the audit log are themselves audited, so an auditor can see who looked. Second, the audit log is about actions on the platform, not about your data. It will tell you that Sam updated the Logstash filter at 10:05 from 198.51.100.7. It will not tell you what changed in the filter, and it does not log individual searches inside OpenSearch Dashboards. Keep your pipeline config in version control so you can line the audit event up with a diff. For what a good audit trail should capture in general, see our audit logging best practices.
Find an event in the dashboard
For a stack, open the stack's Overview page and select Audit Log under Logs in the stack menu. For the account, open Account Settings and select Audit Log in the Manage Account menu. If the option is missing, your role does not include audit log access and an account administrator needs to grant it.
Then narrow it down:
- Type a word from the event or a username in Search by event or username and press Enter. On the account page you can also paste a Stack ID.
- Open Time and pick Last hour, Last 12 hours, Last 24 hours, Last 7 days or Last 30 days.
- Use User to sort A to Z or Z to A if you are reviewing one person's activity.
- Expand a row to see the Stack ID, User ID and exact timestamp.
The search box matches any word you type against the message, username, email, Stack ID and subscription ID. Searching Logstash filter therefore returns Updated Logstash Filter and also Viewed Logstash Input Rules, because both contain "Logstash". Use the most specific single word you can, such as Updated or a username, and then scan the list.
Export CSV or JSON
At the bottom of either audit log page, Export CSV and Export JSON download events for the stack or the account. The export ignores the search box, the Time menu and the User sort. You get the most recent events in the audit log window, newest first, up to the export limit, whatever the page was showing. Filter the file afterwards.
The JSON export uses PascalCase keys and includes IDs that the page hides, which makes it the better choice for evidence:
[
{
"AccountId": "…",
"ResourceId": "…",
"Created": "2026-11-01T10:05:12.481Z",
"SubscriptionId": "…",
"StackId": "…",
"UserId": "…",
"Message": "Updated Logstash Filter",
"IpAddress": "198.51.100.7",
"Username": "sam",
"Email": "[email protected]",
"Timestamp": "2026-11-01T10:05:12.481Z",
"Source": "Dashboard",
"LogitOnly": false,
"MetaData": null
}
]
Filter it with jq. This lists every change event (anything that is not a page view) with the user and IP, oldest first:
jq -r 'sort_by(.Timestamp) | .[]
| select(.Message | startswith("Viewed") | not)
| [.Timestamp, .Username, .IpAddress, .Message] | @tsv' audit.json
And this counts events per user, which is a quick way to spot a service account that is busier than you expected:
jq -r 'group_by(.Username) | map({user: .[0].Username, events: length})
| sort_by(-.events) | .[] | "\(.events)\t\(.user)"' audit.json
The file name contains the date and the stack or account ID. Rename it when you save it as evidence, so the date format does not depend on how your browser treats the name.
Pull it with the API
For anything repeatable, use the Dashboard API. Create a key under API Keys on your Profile page. The key acts as you, so the user who owns it needs audit log access on the stack. Then:
export LOGIT_API_KEY='your-dashboard-api-key'
export LOGIT_STACK_ID='your-stack-id'
curl -sS -H "x-api-key: $LOGIT_API_KEY" \
"https://dashboard.logit.io/api/stacks/$LOGIT_STACK_ID/audit-log?Page=1&PageSize=20&Period=3" | jq .
The response is camelCase and slimmer than the export:
{
"entries": [
{
"timestamp": "2026-11-01T10:07:40Z",
"message": "Removed User contractor-1",
"username": "sam",
"email": "[email protected]",
"ipAddress": "198.51.100.7",
"source": "Dashboard.Api"
}
],
"totalRecords": 52,
"page": 1,
"pageSize": 20
}
The query parameters are:
| Parameter | Values | Default |
|---|---|---|
Page | 1, 2, 3... | 1 |
PageSize | Entries per page | 25 |
Period | 1 = last hour, 2 = 12 hours, 3 = 24 hours, 4 = 7 days, 5 = 30 days | 7 days |
Search | Free text, matched like the search box | None |
Entries come back newest first. There is no stack ID on each entry, because the URL already names the stack, so add it yourself when you store the events. Full request and response schemas are in the API reference.
Ship audit events into logstash-*
Once audit events sit in the same logstash-* indices as your application logs, they inherit your stack's retention, you can put them on the same OpenSearch Dashboards timeline as an incident, and you can alert on them. The setup has three parts: a script that pulls new events and writes NDJSON, a timer that runs it, and a Filebeat input that ships the file.
The script deduplicates. Filebeat tracks its own position in the file, so each line is shipped once.
Step 1: the puller script
Save this as /usr/local/bin/logit-audit-pull.sh. It pages through the window you choose, skips events it has already written, maps each entry to ECS-style fields and appends one JSON object per line to a daily file.
#!/usr/bin/env bash
# Pull new Logit.io stack audit events into an NDJSON file for Filebeat.
set -euo pipefail
: "${LOGIT_API_KEY:?set LOGIT_API_KEY}" "${LOGIT_STACK_ID:?set LOGIT_STACK_ID}"
API=${LOGIT_API:-https://dashboard.logit.io/api}
OUT_DIR=${OUT_DIR:-/var/lib/logit-audit}
PERIOD=${PERIOD:-2} # 1=1h 2=12h 3=24h 4=7d 5=30d
PAGE_SIZE=${PAGE_SIZE:-100}
mkdir -p "$OUT_DIR"
SEEN="$OUT_DIR/seen.txt"; touch "$SEEN"
OUT="$OUT_DIR/audit-$(date -u +%Y%m%d).ndjson"
page=1; new=0
while :; do
resp=$(curl -sS --fail-with-body --max-time 30 \
-H "x-api-key: $LOGIT_API_KEY" \
"$API/stacks/$LOGIT_STACK_ID/audit-log?Page=$page&PageSize=$PAGE_SIZE&Period=$PERIOD")
[ "$(jq '.entries | length' <<<"$resp")" -eq 0 ] && break
while IFS= read -r entry; do
id=$(printf '%s' "$entry" | sha256sum | cut -c1-20)
grep -qxF "$id" "$SEEN" && continue
jq -c --arg stack "$LOGIT_STACK_ID" --arg id "$id" '{
"@timestamp": (.timestamp | if test("(Z|[+-][0-9]{2}:[0-9]{2})$") then . else . + "Z" end),
event: {dataset: "logit.audit", id: $id},
audit: {message: .message, user: .username, email: .email,
source: .source, stack_id: $stack},
source: {ip: .ipAddress}
}' <<<"$entry" >> "$OUT"
echo "$id" >> "$SEEN"
new=$((new + 1))
done < <(jq -c '.entries[]' <<<"$resp")
total=$(jq '.totalRecords' <<<"$resp")
[ $((page * PAGE_SIZE)) -ge "$total" ] && break
page=$((page + 1))
done
tail -n 50000 "$SEEN" > "$SEEN.tmp" && mv "$SEEN.tmp" "$SEEN"
echo "$(date -u +%FT%TZ) pulled $new new audit events into $OUT"
A few choices in there are deliberate:
- The event ID is a hash of the whole entry. Pages overlap between runs, and new events push older ones onto the next page while you are paging. Hashing the entry, time included, means a repeat is always skipped and two genuinely identical clicks a millisecond apart are rare enough not to matter.
- The timestamp gets a
Zif it has no zone. Audit times are UTC, but a value without a zone suffix is not something Filebeat will accept as@timestamp. More on that in the failure modes. - No
Searchparameter. The API resetsPageto 1 wheneverSearchis set, so a paging loop with a search term reads page 1 forever or stops early. Pull everything and filter downstream. - A 12-hour window run hourly. Each run overlaps the last eleven, so a missed run or a slow API call costs nothing.
Our test run against a mock that matches the API's documented response, with 52 events and 20 per page, wrote 52 lines the first time and 0 the second:
$ ./logit-audit-pull.sh
2026-11-02T07:56:04Z pulled 52 new audit events into /var/lib/logit-audit/audit-20261102.ndjson
$ ./logit-audit-pull.sh
2026-11-02T07:56:04Z pulled 0 new audit events into /var/lib/logit-audit/audit-20261102.ndjson
Step 2: run it hourly with systemd
Keep the key out of the unit file. Put it in /etc/logit-audit.env, owned by root and mode 600:
LOGIT_API_KEY=your-dashboard-api-key
LOGIT_STACK_ID=your-stack-id
/etc/systemd/system/logit-audit-pull.service:
[Unit]
Description=Pull Logit.io audit events
After=network-online.target
[Service]
Type=oneshot
EnvironmentFile=/etc/logit-audit.env
ExecStart=/usr/local/bin/logit-audit-pull.sh
/etc/systemd/system/logit-audit-pull.timer:
[Unit]
Description=Pull Logit.io audit events hourly
[Timer]
OnCalendar=hourly
RandomizedDelaySec=300
Persistent=true
[Install]
WantedBy=timers.target
sudo chmod 600 /etc/logit-audit.env
sudo systemctl daemon-reload
sudo systemctl enable --now logit-audit-pull.timer
sudo systemctl start logit-audit-pull.service
journalctl -u logit-audit-pull.service -n 5
For several stacks, run one service per stack with its own env file and its own OUT_DIR, or loop over a list of stack IDs in a wrapper. Keep one seen.txt per stack.
Step 3: ship the file with Filebeat
Add a filestream input to the Filebeat that already ships logs from this host to your stack. The ndjson parser with target: "" puts the fields at the root of the event and lets @timestamp from the file replace the read time.
filebeat.inputs:
- type: filestream
id: logit-audit
paths: ["/var/lib/logit-audit/audit-*.ndjson"]
parsers:
- ndjson:
target: ""
overwrite_keys: true
add_error_key: true
processors:
- drop_fields:
fields: ["audit.email"]
ignore_missing: true
output.logstash:
hosts: ["your-logstash-endpoint-ls.logit.io:your-beats-ssl-port"]
loadbalance: true
ssl.enabled: true
The drop_fields processor is optional. The username and IP address are usually enough to identify who acted, and leaving email addresses out keeps personal data in your log indices to a minimum. Remove it if your auditors want the email.
Copy the Logstash endpoint and Beats SSL port from your stack's settings, or use the Filebeat config the dashboard generates for you. Then check the config and watch the first minute:
sudo filebeat test config
sudo filebeat test output
sudo systemctl restart filebeat
journalctl -u filebeat -f | grep -i 'logit-audit\|error'
In our run, Filebeat 9.5.5 read all 52 lines, set @timestamp from the file and dropped the email:
{"@timestamp":"2026-11-01T10:05:12.481Z",
"event":{"dataset":"logit.audit","id":"b6aa601239c555104fba"},
"audit":{"message":"Updated Logstash Filter","user":"sam",
"source":"Dashboard","stack_id":"…"},
"source":{"ip":"198.51.100.7"}}
Verify it on your stack
Open OpenSearch Dashboards from your stack and go to Discover with the logstash-* index pattern. Set the time range to the last 24 hours and run:
event.dataset:"logit.audit"
Add audit.message, audit.user and source.ip as columns. Three checks prove the pipeline end to end:
- The times match. Pick an event, expand the matching row on the stack's Audit Log page and compare. Discover shows times in your browser's time zone, so check which zone each view uses before comparing.
- The count matches. Compare the number of hits for the last 24 hours with
totalRecordsfromPeriod=3in the API. They should agree within the events that arrived since your last run. - Nothing is doubled. Run this in Dev Tools. An empty bucket list means no event ID was indexed twice:
GET logstash-*/_search
{
"size": 0,
"query": {
"bool": {
"filter": [
{ "term": { "event.dataset.keyword": "logit.audit" } },
{ "range": { "@timestamp": { "gte": "now-7d" } } }
]
}
},
"aggs": {
"repeated_ids": {
"terms": { "field": "event.id.keyword", "min_doc_count": 2, "size": 20 }
}
}
}
On a default Logit.io stack these string fields are mapped as text with a .keyword sub-field, so term queries and aggregations use the .keyword name. Check the field list in your index pattern if your stack differs; mappings are managed for you.
Then save two searches that pay for the work. The first lists every change, hiding page views:
event.dataset:"logit.audit" and not audit.message:Viewed*
The second catches access changes on the account side, if you pull the account audit log too:
event.dataset:"logit.audit" and audit.message:("Removed User" or "Created invite token" or "Granted Account Support Access")
Both make good alert conditions. An alert on Updated Logstash Filter outside working hours is cheap and catches the change that most often explains a sudden drop in parsed fields.
Use it in an incident
The point of having audit events in logstash-* is the shared timeline. Suppose parse failures on your nginx logs jump at 10:06. In Discover, query:
event.dataset:"logit.audit" or tags:_grokparsefailure
Sort by time. If Updated Logstash Filter by sam at 10:05:12 sits one row above the first _grokparsefailure, you have your cause in one screen, with the IP that made the change. Without the audit events in the same indices you would be flipping between the dashboard's Audit Log page and Discover, which may not show times in the same zone, which is exactly where people misread a timeline by an hour.
The same works for quieter questions. "Who has opened OpenSearch Dashboards on the production stack this quarter?" is a terms aggregation on audit.user filtered to Viewed OpenSearch Dashboards, over whatever retention your stack has, rather than a scroll through a page of 25 rows.
Failure modes
@timestamp not overwritten
If you write the API timestamp into the file unchanged and it has no zone suffix, Filebeat keeps the time it read the line and adds an error to the event. This is what our run produced with 2026-11-01T10:05:12.481:
{"@timestamp":"2026-11-02T07:56:33.171Z",
"error":{"type":"json",
"message":"@timestamp not overwritten (parse error on 2026-11-01T10:05:12.481)"}}
The event still arrives, but at the wrong time, so it vanishes from the incident timeline. The jq expression in the script appends Z when the zone is missing. To find events that already went in wrong, search error.message:"@timestamp not overwritten".
The script exits with 401 or 403
curl: (22) The requested URL returned error: 401 means the key is wrong or revoked. 403 means the key is valid but its user cannot use the audit log on that stack. Because the script runs with set -e, the systemd unit fails and systemctl status logit-audit-pull.service shows it. Alert on a failed unit, or a gap in audit events will look like a quiet week.
Search and paging together
Adding Search=Updated to a paging loop looks harmless. The endpoint resets Page to 1 when Search is set, so the loop either sees the same page again or stops after one page. Use Search for one-off lookups only.
The export does not match the screen
You filtered to one user and the last hour, pressed Export JSON, and got everyone's events. That is how the export works. It always returns recent events for the whole stack or account. Filter the file with jq.
History older than the window
The dashboard is a view of recent audit activity, not an archive. If your first run happens after an incident, the events you need may already be outside what the page and the API will return. Start the pull before you need it. For a one-time backfill, run the script once with PERIOD=5 and keep whatever comes back.
Duplicates after deleting seen.txt
Delete seen.txt and the next run writes every event in the window again. Filebeat ships them, because to Filebeat they are new lines. The Dev Tools query above will show them as repeated event.id values. Back up seen.txt with the host, or accept a one-off batch of duplicates and filter on the most recent copy.
Trade-offs
A pull loop adds up to an hour of delay. For audit evidence that is fine. For "alert me the moment someone removes a user", run the timer every five minutes with PERIOD=1. The API is cheap to call and the dedupe makes overlap harmless.
Audit events are small, so even a busy account adds little to daily ingestion compared with application logs. If page views are noise to you, drop them in the script with select(.message | startswith("Viewed") | not) before writing the file, and keep only changes.
Storing audit events in the same indices as application logs means anyone who can search logstash-* can read them. If that is too broad, send them to a separate Logs stack used only by your security team, with its own users.
FAQ
Does the audit log record searches made in OpenSearch Dashboards?
No. It records that someone opened OpenSearch Dashboards from the Logit.io dashboard, along with platform actions such as filter, alert, user and plan changes. Queries inside OpenSearch Dashboards are not audit events.
Can I see what changed in the Logstash filter?
The audit log shows who updated it, when and from which IP, not the diff. Keep your filter in version control and match the commit to the audit event time.
Why does the export ignore my search and time filters?
The export is built to give you the most recent record for the stack or account, regardless of the page filters. Filter the CSV or JSON afterwards.
Which API key should I use for the scheduled pull?
A Dashboard API key from the Profile page of a user who has audit log access on the stack. Create a dedicated key for the job so you can revoke it without breaking anything else, and store it in a root-only env file.
Can I pull the account audit log through the API?
The documented endpoint is per stack. For account events such as invitations and user removals, use Export JSON on the account audit log page, then convert it with a jq filter like the one in the script, mapping .Timestamp, .Message, .Username and .IpAddress.
How long are audit events kept once shipped?
As long as your stack keeps logstash-* data. They follow the same retention as the rest of the logs on that stack.
Keep the record next to your logs
Every Logit.io Logs stack includes managed Logstash and OpenSearch Dashboards, so audit events land in the same place as the logs they explain. The Logit.io audit log feature page summarises what is recorded. The stack audit log API is documented in the API guide. The Hosted OpenSearch overview covers what is managed for you.
Unlock complete visibility with hosted ELK, Grafana, and Prometheus-backed Observability
