Fast Start - Logging
Framework: KernDX | Total time: ~30 minutes
> Persistent, queryable logging via platform events — zero DML in your transaction, survives rollbacks.
Before you start:
- [ ] KernDX package installed in your org
- [ ] Org configured post-install — verify with the Kern app's Health Check (see Installation guide)
- [ ] CLI authenticated (
sf org open -o YourOrgAliasto verify) — or just use the Developer Console (Gear Icon > Developer Console) for all Apex work - [ ] Working in a sandbox or scratch org (not production)
What you'll build: A service class that logs every step of order processing — start, per-record progress, errors, and completion — with all entries correlated under a single ID.
Success looks like: Log entries visible in App Launcher > Kern > Log Entries, showing correlated info/debug/error messages linked to specific Account records. Your test class shows 2/2 pass with 100% coverage.
In one line: kern.LOG_Builder.build().info('Payment processed').forRecord(accountId).emitAt('SVC.charge'); — persisted via platform event, survives rollbacks.
Table of Contents
Expand
Tier 1: See It Work (~2 minutes)
Run these blocks in Execute Anonymous (Developer Console or VS Code) to see logging in action.
Log at every level
kern.LOG_Builder.LogScope scope = kern.LOG_Builder.scope();
try
{
kern.LOG_Builder.build().info('Application started').emitAt('MyApp.initialize');
kern.LOG_Builder.build().debug('Loading configuration').emitAt('MyApp.loadConfig');
kern.LOG_Builder.build().warn('Configuration missing, using defaults').emitAt('MyApp.loadConfig');
kern.LOG_Builder.build().error('Failed to connect').emitAt('MyApp.connect');
}
finally
{
scope.close();
}Open App Launcher > Kern > Log Entries to see your four entries. Each has a log level, message, and class/method context — and all four share the same Correlation ID so you can filter them as a group.
> Why the scope wrapper? Each .emit() publishes a platform event. Without batching, emitting more > than 150 events in one transaction hits the PublishImmediate governor. LOG_Builder.scope() batches > all emissions inside the try/finally into a single flush when scope.close() is called.
Log an exception with full stack trace
try
{
Integer result = 1 / 0;
}
catch(Exception error)
{
kern.LOG_Builder.build().error(error).emitAt('MyApp.calculate');
}Open the log entry — you'll see the exception type (MathException) and the full stack trace captured automatically.
Correlate logs to a record
Account newAccount = new Account(Name = 'Logging Demo Corp', Phone = '555-0100');
insert newAccount;
kern.LOG_Builder.build().info('Account created successfully')
.at('MyApp.createAccount')
.forRecord(newAccount.Id)
.emit();
kern.LOG_Builder.build().info('Ready for processing')
.at('MyApp.createAccount')
.forRecord(newAccount.Id)
.withContext('industry', 'Technology')
.emit();Both log entries link to the Account record. Click View Record on any log entry to jump directly to it.
> When to move to Tier 2: When you want structured logging inside your own classes with automated test > coverage.
Tier 2: Build Your Own (~20 minutes)
> No local project? You can create classes directly in the Developer Console (Gear Icon > Developer Console > > File > New > Apex Class) and run tests from there too (Test > New Run). Paste the code, save, and skip the > sf project deploy start and sf apex run test commands.
Step 1: Create the service class
This service processes Account records and logs every step — start, per-record progress, errors, and completion — correlated under one Scope.
Copy this code exactly as is into force-app/main/default/classes/SVC_OrderProcessor.cls:
/**
* @description Processes Account records with correlated, structured logging at every step.
*
* @see SVC_OrderProcessor_TEST
*
* @author your.name@company.com
*
* @group Order Management
*
* @date February 2026
*/
public with sharing class SVC_OrderProcessor
{
/** @description Context key for the record count. */
@TestVisible private static final String CONTEXT_COUNT = 'count';
/**
* @description Processes a list of Account records with full correlated logging.
*
* @param accounts The Account records to process.
*/
public void processOrders(List<Account> accounts)
{
kern.LOG_Builder.LogScope scope = kern.LOG_Builder.scope();
try
{
kern.LOG_Builder.build()
.info('Order processing started')
.withContext(CONTEXT_COUNT, accounts.size())
.emitAt('SVC_OrderProcessor.processOrders');
for(Account account : accounts)
{
try
{
kern.LOG_Builder.build()
.debug('Processing account')
.forRecord(account.Id)
.emitAt('SVC_OrderProcessor.processOrders');
}
catch(Exception error)
{
kern.LOG_Builder.build()
.error(error)
.forRecord(account.Id)
.emitAt('SVC_OrderProcessor.processOrders');
}
}
kern.LOG_Builder.build()
.info('Order processing completed')
.withContext(CONTEXT_COUNT, accounts.size())
.emitAt('SVC_OrderProcessor.processOrders');
}
finally
{
scope.close();
}
}
/**
* @description Emits a single ERROR-level alert.
*
* @param message The alert message to persist.
*/
public void criticalAlert(String message)
{
kern.LOG_Builder.build()
.error(message)
.emitAt('SVC_OrderProcessor.criticalAlert');
}
}What this code does:
LOG_Builder.scope()opens a batch scope — all.emit()calls inside the try/finally share one Correlation ID and are flushed together atscope.close(), staying well under the 150-event limit.info()/.debug()/.warn()/.error()set the log level.at('ClassName.methodName')records where the log came from.forRecord(account.Id)links the log entry to the Account being processed.withContext(key, value)adds structured key-value data to the entry.emitAt('Class.method')shorthand for.at(...).emit()
Step 2: Deploy and execute
sf project deploy start -o YourOrgAlias -m "ApexClass:SVC_OrderProcessor" --ignore-conflictsTest it from Execute Anonymous:
Account newAccount = new Account(Name = 'Order Demo Corp', Phone = '555-0200', BillingCity = 'Melbourne');
insert newAccount;
new SVC_OrderProcessor().processOrders(new List<Account>{ newAccount });
System.debug('Done — check App Launcher > Kern > Log Entries');> See it in the org: Open App Launcher > Kern > Log Entries. You'll see three entries — an INFO > (started), a DEBUG (per-record), and an INFO (completed) — all sharing the same Correlation ID.
Step 3: Write the test class
Copy this code exactly as is into force-app/main/default/classes/SVC_OrderProcessor_TEST.cls:
/**
* @description Unit tests for SVC_OrderProcessor.
*
* @see SVC_OrderProcessor
*
* @author your.name@company.com
*
* @group Order Management
*
* @date February 2026
*/
@SuppressWarnings('PMD.ApexUnitTestClassShouldHaveRunAs')
@IsTest(SeeAllData=false IsParallel=true)
private class SVC_OrderProcessor_TEST
{
/** @description Log level string for INFO entries. */
private static final String LOG_LEVEL_INFO = 'INFO';
/** @description Log level string for ERROR entries. */
private static final String LOG_LEVEL_ERROR = 'ERROR';
/** @description Alert message used in the error path test. */
private static final String ALERT_MESSAGE = 'Critical system failure';
/**
* @description Verifies that processOrders() creates correlated log entries
* queryable by kern__UserId__c after platform event delivery.
*/
@IsTest
private static void shouldCreateScopedLogEntriesForProcessedAccounts()
{
Account account = (Account)kern.TST_Builder.of(Account.SObjectType).build();
kern.LOG_Builder.ignoreTestMode = true;
Test.startTest();
new SVC_OrderProcessor().processOrders(new List<Account>{ account });
Test.getEventBus().deliver();
Test.stopTest();
// Query by kern__UserId__c — NOT CreatedById.
// TRG_PersistLogEntry runs as the Automated Process user, so CreatedById returns zero rows.
Id currentUserId = UserInfo.getUserId();
List<kern__LogEntry__c> entries = [
SELECT kern__LogLevel__c, kern__CorrelationId__c
FROM kern__LogEntry__c
WHERE kern__UserId__c = :currentUserId
];
Assert.isFalse(entries.isEmpty(), 'Expected correlated log entries after event delivery');
Boolean foundInfo = false;
for(kern__LogEntry__c entry : entries)
{
if(entry.kern__LogLevel__c == LOG_LEVEL_INFO)
{
foundInfo = true;
}
}
Assert.isTrue(foundInfo, 'Expected at least one INFO entry from processOrders()');
}
/**
* @description Verifies that criticalAlert() persists an ERROR entry with the correct message.
*/
@IsTest
private static void shouldPersistErrorEntryForCriticalAlert()
{
kern.LOG_Builder.ignoreTestMode = true;
Test.startTest();
new SVC_OrderProcessor().criticalAlert(ALERT_MESSAGE);
Test.getEventBus().deliver();
Test.stopTest();
// Query by kern__UserId__c — NOT CreatedById (see note in test above)
Id currentUserId = UserInfo.getUserId();
List<kern__LogEntry__c> entries = [
SELECT kern__Message__c, kern__LogLevel__c
FROM kern__LogEntry__c
WHERE kern__UserId__c = :currentUserId AND kern__LogLevel__c = :LOG_LEVEL_ERROR
];
Assert.isFalse(entries.isEmpty(), 'Expected one ERROR entry from criticalAlert()');
Assert.areEqual(ALERT_MESSAGE, entries[0].kern__Message__c, 'Message should match the alert text');
}
}> About the query filter: Tests must filter kern__LogEntry__c by kern__UserId__c, not > CreatedById. The platform trigger that persists log entries (TRG_PersistLogEntry) runs as the > Automated Process user, so CreatedById returns zero rows. kern__UserId__c is set to the ID of the > user who emitted the log.
> About the annotations: @IsTest(SeeAllData=false IsParallel=true) keeps tests isolated and > enables parallel execution. @SuppressWarnings('PMD.ApexUnitTestClassShouldHaveRunAs') suppresses a > code analysis rule — in production tests, consider wrapping logic in System.runAs(testUser) to > verify profile and permission set access.
Step 4: Deploy and run tests
sf project deploy start -o YourOrgAlias -m "ApexClass:SVC_OrderProcessor_TEST" --ignore-conflicts
sf apex run test -o YourOrgAlias -t SVC_OrderProcessor_TEST --code-coverage --synchronous --result-format humanExpected output:
=== Test Results
Tests Ran 2
Passing 2
Failing 0Key Patterns
| Pattern | Example | Why |
|---|---|---|
| Scope batching | LOG_Builder.scope() + try/finally | Prevents PE governor hit; groups entries under one Correlation ID |
| Log at method boundaries | .info('started') + .info('completed') | Trace execution flow in production |
| Per-record context | .forRecord(account.Id) | All logs for one record queryable together |
| Debug inside loops | .debug('Processing account') | Filtered in production; visible in sandbox |
| Catch-log-continue | .error(error) in catch, no rethrow | Persistent record; processing continues for remaining items |
| Single-shot alert | .error('message').emitAt(...) | One-liner for config/startup errors |
| Query by UserId | kern__UserId__c = UserInfo.getUserId() | NOT CreatedById — TRG_PersistLogEntry runs as Automated Process user |
Tier 3: Production Patterns (~5-10 minutes)
Log levels
See the Logging Guide for the complete log level reference and filtering configuration.
| Level | Method | When to use |
|---|---|---|
| ERROR | .error(exception) or .error('message') | Failures, exceptions, data corruption |
| WARN | .warn('message') | Business rule violations, missing optional data, degraded performance |
| INFO | .info('message') | Key business events, state transitions, completion messages |
| DEBUG | .debug('message') | Implementation details, variable values (filtered in production) |
Flood control for repeated events
When the same event fires in a hot loop, tag it with a fingerprint so it doesn't flood the log table — the first occurrence keeps a full entry and repeats roll up into a daily counter:
kern.LOG_Builder.build()
.warn('Payment gateway retry failed')
.withFingerprint('payment-gateway-retry')
.emitAt('PaymentSync.run');Use a stable identity for the kind of event (never a record Id or timestamp). See Log Grouping & Flood Control in the Logging Guide for reading grouped logs and reporting on occurrence counts.
Shorthand: .emitAt()
When you don't need .forRecord() or .withContext(), use .emitAt() as shorthand for .at().emit():
// These are equivalent:
kern.LOG_Builder.build().info('Done').at('MyClass.myMethod').emit();
kern.LOG_Builder.build().info('Done').emitAt('MyClass.myMethod');Use .emitAt() for simple messages. Use .at() + chain + .emit() when adding .forRecord() or .withContext().
Exception logging
Always pass the Exception object — never .getMessage():
catch(Exception error)
{
// Captures exception type, message, AND full stack trace
kern.LOG_Builder.build().error(error).at('MyClass.myMethod').forRecord(recordId).emit();
throw error;
}Testing with logs
By default, logs don't emit in @IsTest context. Set kern.LOG_Builder.ignoreTestMode = true before your log call to enable logging in tests, then call Test.getEventBus().deliver() to deliver the platform events synchronously before asserting.
Here's the complete working pattern:
kern.LOG_Builder.ignoreTestMode = true;
Test.startTest();
new SVC_OrderProcessor().processOrders(accounts);
Test.getEventBus().deliver();
Test.stopTest();
// Query by kern__UserId__c — NOT CreatedById
Id currentUserId = UserInfo.getUserId();
List<kern__LogEntry__c> entries = [
SELECT kern__Message__c
FROM kern__LogEntry__c
WHERE kern__UserId__c = :currentUserId
];
Assert.isFalse(entries.isEmpty(), 'Should have log entries');> Key steps: ignoreTestMode = true → emit logs → Test.getEventBus().deliver() → query by > kern__UserId__c → assert.
> Why inline SOQL for kern__LogEntry__c? When a managed-package object is referenced through > kern.QRY_Builder.selectFrom(<kern__sobject>.SObjectType) and then chained with .condition(<kern__sobject>.<kern__field>), > Apex's namespace tokenizer struggles with the doubly-prefixed field token. Inline SOQL is the simplest > idiomatic pattern for subscriber tests against managed-package objects when no SEL_* selector exists. > For your own subscriber objects, kern.QRY_Builder works without issue.
Scoped logging
LOG_Builder.scope() groups all log entries emitted inside a try/finally block under a single Correlation ID, and batches their platform event publications into one flush at scope.close(). This keeps you well clear of the 150-event PublishImmediate governor limit.
kern.LOG_Builder.LogScope scope = kern.LOG_Builder.scope();
try
{
kern.LOG_Builder.build().info('Batch started').emitAt('BATCH_ProcessAccounts.execute');
// ... process records ...
kern.LOG_Builder.build().info('Batch completed').emitAt('BATCH_ProcessAccounts.execute');
}
finally
{
scope.close();
}Run this from Execute Anonymous to see correlated scoped entries:
kern.LOG_Builder.LogScope scope = kern.LOG_Builder.scope();
try
{
kern.LOG_Builder.build().info('Step 1: Loading data').emitAt('MyApp.processRecords');
kern.LOG_Builder.build().info('Step 2: Validating').emitAt('MyApp.processRecords');
kern.LOG_Builder.build().info('Step 3: Complete').emitAt('MyApp.processRecords');
}
finally
{
scope.close();
}
System.debug('Check App Launcher > Kern > Log Entries — all three entries share the same Correlation ID.');Logging from Flows
Use invocable actions to log from Flows. This creates correlated log entries visible in the same Log Entries tab as Apex logs.
- Open Setup > Flows and edit your Flow (or create a new one)
- Add an Action element:
- Search for Start Flow Correlation
- Set Label to
Start Logging - Set flowName to the name of your Flow (e.g.,
Account_Onboarding) - Set recordId to the record ID variable (e.g.,
{!recordId}) - Store the output correlationId in a text variable (e.g.,
{!correlationId})
- At key steps, add Action elements:
- Search for Log Flow Event
- Set correlationId to
{!correlationId} - Set message to a description (e.g.,
Account created successfully) - Set Log Level (input name
logLevel) toINFO,WARN, orERROR
- At the end of the Flow, add an Action element:
- Search for End Flow Correlation
- Set flowName to the same Flow name used in step 2
- Set correlationId to
{!correlationId} - Set Success (input name
success) to{!$GlobalConstant.True}(orFalsefor failure paths)
- Save and Activate
All log entries from the Flow share the same Correlation ID, so you can filter them together in App Launcher > Kern > Log Entries.
Logging from LWC
LWC logging uses the built-in consoleLog() and consoleError() methods from ComponentBuilder. These write to the browser console (not to kern__LogEntry__c). For server-side persistent logging, use LOG_Builder in your Apex controller methods.
// In any ComponentBuilder LWC:
this.consoleLog('User clicked save');
this.consoleError('Save failed', error);See the LWC Guide for the full ComponentBuilder reference, or the Logging Guide for server-side logging patterns.
Sensitive data is masked by default
Log entries pass through the data masking framework before persistence. Out of the box, two rules fire:
MaskSecretKeys— redacts common secret JSON keys (password,token,apiKey,authorization,bearer,client_secret,private_key,access_token,refresh_token) anywhere they appear in a field value.MaskPaymentCard— redacts 13–19 digit sequences that pass the Luhn (mod-10) checksum, covering all major card brands; digits may be separated by spaces or hyphens. The Luhn check filters out most transaction IDs, order numbers, and other long digit runs that would otherwise false-positive as card data. (Replaces the originalMaskCreditCardrule, which still ships for compatibility.)
So LOG_Builder.build().info('Payload: ' + JSON.serialize(payload)).emitAt(...) is safe even if payload contains a password or card number — the persisted LogEntry__c.Message__c will have them redacted. Fifteen more rules (SSN, IBAN, SWIFT/BIC, MBI, health keywords, email, US phone, JWT, AWS access key, URL basic auth, authorization header, private IPv4, postal address, free text, international phone) ship as inactive templates — flip kern__MaskingRule__mdt.IsActive__c = true and add a kern__MaskingTarget__mdt record wiring the rule to the fields that need it.
> Wiring gotcha. Rules carry two optional filters that override explicit target wiring: > ApplicableFieldTypes__c (which DisplayTypes the rule fires on) and MinInputLength__c (minimum value > length). If a MaskingTarget__mdt points at a specific Field__c whose type or value length is rejected > by the rule's filter, the rule will not fire on that field. For ApplicableFieldTypes__c mismatches > the framework logs a one-time warn LogEntry to surface the misconfiguration; MinInputLength__c > mismatches are silent. Either widen the rule filter or remove the target.
> Masking performance telemetry. Masking runs inside trigger dispatch. When a batch's masking duration > is material, enable LogSetting__c.EnableMaskerPerformanceLogging__c (default off) to emit one aggregate > LogEntry__c per trigger batch (not per record). The log fires only when total masking time for that > batch meets or exceeds LogSetting__c.MaskerPerformanceThresholdMs__c (default 100ms).
Common Issues
| Problem | Cause | Fix |
|---|---|---|
| No log entries appear in tests | Logs suppressed in test context | Set kern.LOG_Builder.ignoreTestMode = true before logging |
Log entries empty after ignoreTestMode | Platform events not delivered | Call Test.getEventBus().deliver() after emitting and before asserting |
| Query returns zero rows in tests | Filtering by CreatedById instead of kern__UserId__c | Use kern__LogEntry__c.kern__UserId__c = UserInfo.getUserId() — TRG_PersistLogEntry runs as the Automated Process user, so CreatedById never matches the running user |
Variable does not exist: kern__LogEntry__c | Missing namespace prefix | Use kern__LogEntry__c and kern__FieldName__c (double underscore) for SObjects and fields |
.error(error.getMessage()) loses stack trace | Passing String instead of Exception | Use .error(error) to pass the Exception object directly |
| Logs have no method context | Missing .at() or .emitAt() | Always include .emitAt('Class.method') or .at('Class.method') |
| PE governor error after many emits | >150 PublishImmediate events in one transaction | Wrap emit calls in kern.LOG_Builder.scope() + try/finally to batch the flush |
Test.getEventBus() not found | Running outside @IsTest context | Test.getEventBus().deliver() only works inside test methods |
| Sensitive value appears raw in a log | Field not covered by a masking target, or rule is inactive | Add a kern__MaskingTarget__mdt record with the rule, SObjectType, and field (blank Field__c for a wildcard) — see Logging Guide |
What You Now Know
| Concept | What it does |
|---|---|
kern.LOG_Builder.build() | Creates a fluent log entry builder |
.info() / .warn() / .error() / .debug() | Sets the log level |
.at('Class.method') | Records where the log came from |
.forRecord(Id) | Links the log entry to a specific record |
.withContext(key, value) | Adds structured key-value data |
.withSummary(message) | Sets a short searchable summary on the entry |
.emitAt('Class.method') | Shorthand for .at().emit() |
.emit() | Publishes the log as a platform event |
LOG_Builder.scope() | Batches all emits under one Correlation ID; prevents PE governor hit |
ignoreTestMode = true | Enables logging in test context |
Test.getEventBus().deliver() | Delivers platform events synchronously in tests |
kern__UserId__c | The field to filter on when querying log entries — NOT CreatedById |
Key patterns:
- Wrap bulk logging in
LOG_Builder.scope()+ try/finally (batches emits, prevents governor hit) - Log at method boundaries (entry + exit) for execution tracing
- Always pass
Exceptionobjects, not.getMessage()strings - Use
.forRecord()on every log call to enable record-level filtering - Add
.withContext()for structured data instead of string concatenation - In tests:
ignoreTestMode = true→ emit →Test.getEventBus().deliver()→ query bykern__UserId__c→ assert
Next Steps
| Topic | Link |
|---|---|
| Fast Start - Feature Flags | Fast Start - Feature Flags |
| Fast Start - Test Data | Fast Start - Test Data |
| Code Scanning (catch System.debug anti-patterns) | Fast Start - Code Scanning |
| Logging Developer Guide | Logging - Guide |
| LOG_Builder API Reference | reference/apex/LOG_Builder.md |
| LogEntry__c Object | reference/objects/LogEntry__c.md |
| Flow Logging | reference/apex/FLOW_LoggerStart.md |