sf-shield-events
v0.3.0
Published
Extract Salesforce Shield Event Monitoring logs and turn them into Rill dashboards
Downloads
479
Maintainers
Readme
sf-shield-events
An sf CLI plugin that pulls Salesforce Shield Event Monitoring logs out of an org into a
directory of Parquet tables you can ask questions of with DuckDB, with
the usual questions already written. Optionally, a Rill project
over the same tables, or an MCP server so an assistant can ask.
sf shield events discover --target-org my-org
sf shield events extract --target-org my-org --event-type ApexExecution --event-type Login
cd output && duckdb -init shield.sql # then, at the DuckDB prompt:
.read queries/slow_apex_entry_points.sqlTwo commands and a database: find out what the org has, download it, ask. Two more, both optional, put dashboards over the same tables or serve them to an AI assistant.
Status
Validated end to end against a Shield org, the whole catalogue: 34 event types, 665 log files, 98 MB, streamed into 464,031 rows across 34 typed Parquet tables in 95 seconds at a peak of 363 MB of memory. The tables come to 6 MB. Every one of the six ready-made queries ran, and Rill reconciled the 102 resources of the optional project with no warnings.
Also measured, on a production org: one day of one event type is a 412 MB log file, and the largest seen is 910 MB, which is why nothing here buffers a download (specs/0006).
Why this exists
Shield Event Monitoring answers questions worth asking. Which integration is burning the API
limit, which Apex entry point got slower this week, who exported a report at two in the morning.
The data is there, in EventLogFile, and getting to it is the problem: one CSV per event type
per day, delivered as a blob you have to fetch record by record, with columns that differ by
event type and change between API versions.
So most people either buy a product for it, or write the same throwaway script again. This is that script, written once, with the parts that usually rot replaced by inference, and the questions people actually ask kept as SQL files you can read and change (specs/0007).
Installing it
sf plugins install sf-shield-eventsThe CLI will say it cannot verify the publisher, because the plugin is not signed by Salesforce.
That is expected. The plugin carries DuckDB as a native dependency, about 115 MB once installed;
behind a corporate proxy the install honours npm's proxy settings (HTTPS_PROXY, or
npm config set proxy) like any other plugin.
Requires Node 22.6 or later, which the sf CLI already bundles. DuckDB comes with the plugin,
so the tables are written without installing anything else; to ask questions of them you want
the DuckDB CLI, any version. Only the optional
dashboards need Rill.
What the commands do
sf shield events discover
Asks the org which event types it has logs for, and for each one how many files, how large, and which days they cover. No list of event types is built into this tool: the answer depends on the edition, on which Shield features are licensed, and on what happened in the org inside the retention window.
An org with Shield or the Event Monitoring add-on generates event log files by default and
keeps them for thirty days. A Developer Edition or trial org has to opt in first, under Setup >
Event Monitoring Settings > Generate event log files, and keeps one day; without the opt-in the
org has no files at all, which discover reports as none rather than as an error.
sf shield events extract
Downloads the log files for the event types you name and writes one Parquet table per type,
then makes the directory a database. Name no types and, in a terminal, it asks instead: a list
of the org's event types with the files, size and days each one costs, then the first and last
day, proposed as what you picked covers. Before downloading it prints the command line that runs
the same extraction without a question, so the second time can be a script
(specs/0009). Outside a terminal, with --json or
with --no-prompt, --event-type is required, and a pipeline never sees a prompt.
Either way it makes the directory a database: shield.sql opens every table as a view, and
queries/ holds one SQL file per question people bring to event logs.
The columns of a table are the union of what the org declares across the matching files, not the columns of the first one. Salesforce changes them between API versions, and an extraction spanning that change should not lose the difference.
Files are streamed rather than buffered, because they are large: in a busy production org they average 57 MB and the largest measured was 910 MB. The command reports the total download before it starts and asks before anything over 500 MB, since a single day of one busy event type can mean half a gigabyte (specs/0006). A download that fails is retried, an expired session is refreshed, and a retry never doubles a file's rows. Each table is then typed and converted by DuckDB under a fixed memory limit, and the conversion is checked row for row: a table that lost a row fails the command rather than reporting success.
Types are given only where nothing is lost: whole numbers, dates, timestamps, booleans. A packed Salesforce timestamp, a version number or a key prefix stays text exactly as the org wrote it (specs/0007).
The flags: --event-type (-e), repeated for each type, or left out to choose from a list; --start-date and --end-date as
YYYY-MM-DD, both inclusive; --output-dir, output by default; --no-prompt to skip the
question above 500 MB; --interval, Daily by default or Hourly, in an org that keeps both,
because reading both would count every event twice; --concurrency, how many event types
download at once, four by default, with the files of one type always arriving in order. A type
that fails is reported at the end, after the others have been written, and --json returns the
tables written as data.
sf shield events extract -o my-org -e ApexExecution --start-date 2026-09-01 --end-date 2026-09-07
cd output
duckdb -init shield.sql
.read queries/slow_apex_entry_points.sqlThe six questions shipped, one file each under queries/:
| File | Question | Needs |
| ------------------------------ | ------------------------------------------------------------- | ---------------- |
| api_usage_by_client.sql | Which client is burning the API limit, per day | ApiTotalUsage |
| slow_apex_entry_points.sql | Which Apex entry point got slower this week, against the last | ApexExecution |
| report_runs_out_of_hours.sql | Who ran a report outside working hours, and how many rows | AsyncReportRun |
| login_failures.sql | Failed logins by user and source address | Login |
| dml_volume_by_user.sql | Who wrote the most rows, per day, by operation and object | DatabaseSave |
| rest_api_errors.sql | REST calls that failed, by resource, status and client | RestApi |
The queries are opinionated on purpose and name real columns, which nothing else in the tool
does. Read queries/README.md, edit them, add your own: a later extraction adds queries it
ships that are missing and leaves the ones already there alone. A query over an event type the
org lacks fails with "table not found", which is the correct answer.
sf shield events rill (optional)
Reads the tables and writes a Rill project over them: a model, a metrics view and a dashboard per
event type. Then rill start output. The tables are already a database without this; Rill adds
a point-and-click explorer for the reader who would rather not type SQL. --input-dir names the
directory, output by default.
What each column is for comes from the data, not from its name. A column of numbers gets a total and an average; a column of ISO timestamps can be the time axis; a column with few distinct values groups rows; a column with a distinct value per row gets counted instead. An event type this tool has never seen still produces a usable dashboard, which is the whole point (specs/0002).
The project is written beside the tables so rill start output works and the directory can be
moved as one thing.
sf shield events mcp (optional)
Serves the tables to a Model Context Protocol client over
stdio, so an assistant can list the event types, learn what each column is for, run the
ready-made questions and ask its own. The server reads a directory that extract wrote and
never talks to the org.
Setting it up
Install the plugin and extract something, as above. The server needs a directory with at least one Parquet table in it;
sf shield events mcpon an empty directory exits with "No Parquet tables".Check that the server starts from a shell. It waits for a client on stdin, so end it with Ctrl+C:
sf shield events mcp --input-dir /path/to/outputThe one line it prints goes to stderr, and says how many tables it is serving.
Add it to the client as a stdio server. The directory should be an absolute path, because the client decides the working directory, not you.
Claude Code, in the project you are working in:
claude mcp add shield-events -- sf shield events mcp --input-dir /path/to/outputClaude Desktop, in
claude_desktop_config.json(macOS:~/Library/Application Support/Claude/, Windows:%APPDATA%\Claude\):{ "mcpServers": { "shield-events": { "command": "sf", "args": ["shield", "events", "mcp", "--input-dir", "/path/to/output"] } } }Any other MCP client takes the same three things: the command
sf, the arguments, and the name you give the server. If the client cannot findsf, give it the full path fromwhich sf.Restart the client, or reload its servers, and ask it what event types the extraction has.
Using it
The server offers five tools, and its instructions tell the assistant to start with the first and the third:
| Tool | What it answers |
| ---------------- | -------------------------------------------------------------------------- |
| list_tables | The event types in the extraction, each with its row count |
| describe_table | One table's columns: DuckDB type and role, with no values shown |
| list_questions | The ready-made questions in queries/, each with the event types it needs |
| run_question | One of those questions, by name |
| query | One SELECT in DuckDB SQL over the tables |
A role is what the column is for, inferred from its values: a dimension groups rows, an
identifier is distinct per row and worth counting rather than grouping, a numeric column is
worth summing, a timestamp can be a time axis. Things worth asking, in words:
- "Which event types do we have, and how many rows each?"
- "Run the login failures question."
- "Which client burned the most API calls last week, by day?"
- "In ApexExecution, which entry points had the highest average CPU time?"
- "Describe the AsyncReportRun table and tell me which columns group the rows."
Every answer is capped at --row-limit rows, 200 by default, and says when it was cut. An
assistant that needs more should aggregate or page with OFFSET; the cap is a guard on volume,
not a limit on what can be asked. Times are TIMESTAMP_DERIVED, written by Salesforce in UTC.
What it will not do
The server is read-only in the strongest sense DuckDB offers. Only a single SELECT runs, and
that is decided by DuckDB's own parser, so COPY, CREATE, INSTALL and SET are refused
whatever they look like. DuckDB is confined to the extraction directory and locked there, so a
SELECT over any other file on the machine fails with a permission error. describe_table
shows no values.
The rows are still unredacted production logs, and the client is usually a model running elsewhere. Point this at an extraction you would be willing to paste into that model (specs/0008).
If it does not work
- The client says the server failed to start. Run the command from step 2 in a shell. A
wrong
--input-diror a missingsfon the client's PATH shows up there. - The assistant sees no tools. The directory has no Parquet files. Run
extractfirst, or point--input-dirat where its output went. - A question fails with "table not found". The extraction does not have that event type.
That is the correct answer;
list_questionssays what each one needs.
What it does not do
It does not store credentials. The plugin borrows the connection the sf CLI already has, so
there is no token, no instance URL and no .env anywhere in it.
It does not filter or redact. What comes out of the org goes into the files, and event logs carry
user ids, URIs, query text and IP addresses. Treat the output directory the way you treat the
org: .gitignore covers it here, and deleting it when the analysis is done is the habit worth
having.
It does not do Real-Time Event Monitoring, the streaming platform events. This reads
EventLogFile, the batch side.
Working on it
npm install
npm run check # lint, typecheck and tests
npm run build # both bin entrypoints load lib/, so build before running
./bin/run.js shield events discover -o my-orgTests run on the Node test runner against TypeScript directly, so there is no test framework and
no build step for them. The table tests run a real DuckDB on files a few lines long, and one
test hands a generated project to rill validate when Rill is installed.
docs/ARCHITECTURE.md describes how the pieces fit. specs/ records why each decision was made, with the measurements behind it.
License
Apache License 2.0. See LICENSE and NOTICE, and specs/0005 for why this and not something restrictive.
Copyright 2026 Adilson Arcoverde.
