Rollback-safe delivery
A logging framework has one hard problem on a transactional platform: a log about a failure must survive the rollback that failure causes. If logs are written with ordinary DML inside the transaction, the rollback that discards the bad record also discards the log explaining why — you are blind exactly when you most need to see. This page explains how CAOS solves that with mechanisms the kernel already owns, and why that lets us drop the platform-event machinery Nebula was forced to build.
The two outcomes need two different guarantees
Section titled “The two outcomes need two different guarantees”The rollback problem is usually framed as one requirement (“logs must survive rollback”), but it is really two requirements that pull in opposite directions:
- A committed save’s logs should be atomic with the record. If the save succeeds, its log should exist if and only if the record exists — no window where the record is durable but its log is still in flight, and no orphan log for a record that isn’t there.
- A rolled-back save’s logs must survive anyway. If the save fails, the record is gone but the explanation must remain — that is the whole point of logging a failure.
You cannot satisfy both from a single write path inside one transaction: atomicity binds the log to the transaction’s fate, and surviving rollback requires escaping it. Nebula resolves the tension by giving up requirement 1 entirely — every log goes out of band via a platform event, so a committed save’s log is never atomic with its record and always arrives asynchronously. CAOS resolves it by routing on the outcome the kernel already knows.
How CAOS routes
Section titled “How CAOS routes”CAOS buffers log entries in memory during the save (exactly as it buffers effects), then delivers them by the transaction’s actual outcome:
| Outcome | Delivery path | Guarantee |
|---|---|---|
| Committed | Entries are staged into the transactional outbox during the EFFECTS phase, commit atomically with the record, and are materialized into durable Log / Log Entry records in POST-COMMIT | Atomic with the record. The log exists exactly when the record does; no orphans, no lost success logs. |
| Rolled back | The buffered entries — including the error envelope that aborted the save — are handed to the out-of-band telemetry sink on a separate connection, independent of the doomed transaction | Survives the rollback. The failure is recorded even though every row the transaction wrote is discarded. |
The developer writes log.info(...) once. The kernel picks the path, because the kernel is the thing that commits or rolls back and therefore is the only component that can pick correctly.
Why the outbox is the right success path
Section titled “Why the outbox is the right success path”The outbox already exists to solve the identical problem for outbound messages: a message enqueued during EFFECTS is inserted on the transaction-scoped connection, so it commits atomically with the record and is published only if the save succeeds — never “sent but rolled back.” Log entries for a committed save want exactly that property: recorded atomically, published post-commit, never for a save that didn’t happen. Riding the outbox means logs inherit the outbox’s at-least-once post-commit delivery and its worker-backed retry/dead-letter behavior with no new infrastructure.
Why failures use the telemetry sink
Section titled “Why failures use the telemetry sink”CAOS already writes failure detail out of band: the error normalizer captures the raw detail of an internal error to a telemetry sink keyed by correlation id, outside the user’s transaction, so it survives the rollback that produced the error. Failure-path logging generalizes that existing seam: when a save aborts, its buffered entries and the aborting envelope are written through the same out-of-band sink. This is the native equivalent of Nebula’s “publish immediately so it survives rollback” — same guarantee, but as a direct out-of-band write the kernel already performs, not a platform-event round trip.
What we deliberately drop: the platform-event indirection
Section titled “What we deliberately drop: the platform-event indirection”Nebula’s LogEntryEvent__e platform event is an elegant solution to a problem CAOS does not have. On Salesforce, Apex cannot write a record that survives a rollback — a DML insert of a Log__c is rolled back with everything else. The only primitive that escapes the transaction is a platform event published with “Publish Immediately” behavior, which fires from the event bus even if the enclosing Apex transaction later throws. So Nebula routes all logging through a platform event: Logger.saveLog() publishes LogEntryEvent__e records, and a subscriber trigger (LogEntryEventHandler) asynchronously converts them into Log__c and LogEntry__c rows.
That architecture carries real, permanent costs — none of which are Nebula’s fault, all of which are the platform’s:
- Logs are always asynchronous and eventually consistent. Even for a transaction that commits cleanly, the
Log__crecords appear after a separate subscriber transaction runs. There is an inherent window where the business record exists but its log does not. - A whole second object exists only as a transport.
LogEntryEvent__eduplicates the field surface ofLogEntry__cpurely to move data across the transaction boundary; the two must be kept in sync. - The developer must choose a save method and live with its trade-offs.
EVENT_BUS(async, survives rollback),QUEUEABLE(async DML),REST(avoids mixed-DML), andSYNCHRONOUS_DML(immediate, but rolled back on failure — the exact footgun) each behave differently under rollback and ordering. The choice is exposed because no single method is right for all cases. - Publish limits and buffer/heap pressure become the developer’s concern — platform events count against their own governor limits, and a large in-memory buffer competes for heap.
CAOS has the primitive Apex lacks: the kernel can write out of band whenever it wants, and it knows the transaction outcome. So there is nothing to transport across a boundary the platform won’t let you cross, and no reason to make the developer choose a delivery mode. We do not replicate LogEntryEvent__e, LogEntryEventHandler, or the SaveMethod enum. Dropping them removes an object, a subscriber, an async-consistency window, and a decision the developer should never have had to make.
Ordering, duplicates, and honesty about outcome
Section titled “Ordering, duplicates, and honesty about outcome”- Ordering. Entries carry their phase and a monotonic sequence within the transaction, so a Log’s entries render in execution order regardless of delivery path — the success path preserves order through the outbox, the failure path preserves it in the buffer handed to the sink.
- At-least-once, deduped by id. Post-commit publication is at-least-once (a worker may retry); each entry carries a stable id so a replayed publish is idempotent, not a duplicate. This reuses the outbox’s existing idempotency substrate.
- The Log never lies about the outcome. A committed Log is stamped
committed; a rolled-back Log is stampedrolled_backwith the aborting reason. A reader can always tell whether the transaction the log describes actually happened — unlike a raw debug log, where a rolled-back save and a committed one look identical in the trace.
Sources
Section titled “Sources”- Salesforce Ben — How to Debug Salesforce Flow, Apex, and LWC With Nebula Logger — Nebula uses
LogEntryEvent__eso entries are published even if an error occurs and the transaction rolls back. - Nebula Logger — project README (jongpie/NebulaLogger) — the
SaveMethodenum (EVENT_BUS,QUEUEABLE,REST,SYNCHRONOUS_DML) and the caveat thatSYNCHRONOUS_DMLis rolled back on exception. - Salesforce — Platform Events and Transactions — “Publish Immediately” events fire regardless of transaction success; the primitive Nebula depends on and CAOS does not need.