diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8ab8326b3..6e3d8d82d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,16 +8,20 @@ env: on: push: + branches: + - main paths: - .github/workflows/** - config/scratch-orgs/** - nebula-logger/** + - sfdx-project.json pull_request: types: [opened, synchronize, reopened] paths: - .github/workflows/** - config/scratch-orgs/** - nebula-logger/** + - sfdx-project.json jobs: code-quality-tests: diff --git a/README.md b/README.md index 06897fbec..314e150d5 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,15 @@ The most robust observability solution for Salesforce experts. Built 100% natively on the platform, and designed to work seamlessly with Apex, Lightning Components, Flow, Process Builder & integrations. -## Unlocked Package - v4.14.10 +## Unlocked Package - v4.14.11 -[![Install Unlocked Package in a Sandbox](./images/btn-install-unlocked-package-sandbox.png)](https://test.salesforce.com/packaging/installPackage.apexp?p0=04t5Y0000015oTdQAI) -[![Install Unlocked Package in Production](./images/btn-install-unlocked-package-production.png)](https://login.salesforce.com/packaging/installPackage.apexp?p0=04t5Y0000015oTdQAI) +[![Install Unlocked Package in a Sandbox](./images/btn-install-unlocked-package-sandbox.png)](https://test.salesforce.com/packaging/installPackage.apexp?p0=04t5Y0000015oUHQAY) +[![Install Unlocked Package in Production](./images/btn-install-unlocked-package-production.png)](https://login.salesforce.com/packaging/installPackage.apexp?p0=04t5Y0000015oUHQAY) [![View Documentation](./images/btn-view-documentation.png)](https://github.com/jongpie/NebulaLogger/wiki) -`sf package install --wait 20 --security-type AdminsOnly --package 04t5Y0000015oTdQAI` +`sf package install --wait 20 --security-type AdminsOnly --package 04t5Y0000015oUHQAY` -`sfdx force:package:install --wait 20 --securitytype AdminsOnly --package 04t5Y0000015oTdQAI` +`sfdx force:package:install --wait 20 --securitytype AdminsOnly --package 04t5Y0000015oUHQAY` --- @@ -40,6 +40,7 @@ The most robust observability solution for Salesforce experts. Built 100% native - [Lightning Components](https://github.com/jongpie/NebulaLogger/wiki/Logging-in-Components): lightning web components (LWCs) & aura components - [Flow & Process Builder](https://github.com/jongpie/NebulaLogger/wiki/Logging-in-Flow): any Flow type that supports invocable actions - [OmniStudio](https://github.com/jongpie/NebulaLogger/wiki/Logging-in-OmniStudio): omniscripts and omni integration procedures + - [OpenTelemetry (OTel) REST API](https://github.com/jongpie/NebulaLogger/wiki/Logging-in-OpenTelemetry-REST-API): inbound integrations, using HTTP and [OTel's JSON format for logs](https://github.com/open-telemetry/opentelemetry-proto/blob/main/examples/logs.json) 2. Built with an event-driven pub/sub messaging architecture, using `LogEntryEvent__e` [platform events](https://developer.salesforce.com/docs/atlas.en-us.platform_events.meta/platform_events/platform_events_intro.htm). For more details on leveraging platform events, see [the Platform Events Developer Guide site](https://developer.salesforce.com/docs/atlas.en-us.platform_events.meta/platform_events/platform_events_subscribe_cometd.htm) diff --git a/docs/apex/Log-Management/LoggerRestResource.md b/docs/apex/Log-Management/LoggerRestResource.md new file mode 100644 index 000000000..17446677f --- /dev/null +++ b/docs/apex/Log-Management/LoggerRestResource.md @@ -0,0 +1,193 @@ +--- +layout: default +--- + +## LoggerRestResource class + +REST Resource class for external integrations to interact with Nebula Logger + +--- + +### Properties + +#### `body` → `String` + +#### `endpointRequest` → `EndpointRequest` + +#### `errors` → `List` + +#### `headerKeys` → `List` + +#### `httpMethod` → `String` + +#### `isSuccess` → `Boolean` + +#### `message` → `String` + +#### `name` → `String` + +#### `parameters` → `Map` + +#### `particle` → `String` + +#### `statusCode` → `Integer` + +#### `type` → `String` + +#### `uri` → `String` + +--- + +### Methods + +#### `EndpointError(System.Exception apexException)` → `public` + +#### `EndpointError(String message)` → `public` + +#### `EndpointError(String message, String type)` → `public` + +#### `EndpointRequest(System.RestRequest restRequest)` → `public` + +#### `addError(System.Exception apexException)` → `EndpointResponse` + +#### `addError(EndpointError endpointError)` → `EndpointResponse` + +#### `handlePost()` → `void` + +Processes any HTTP POST requests sent + +#### `handlePost(EndpointRequest endpointRequest)` → `EndpointResponse` + +#### `handlePost(EndpointRequest endpointRequest)` → `EndpointResponse` + +#### `handlePost(EndpointRequest endpointRequest)` → `EndpointResponse` + +#### `setStatusCode(Integer statusCode)` → `EndpointResponse` + +--- + +### Inner Classes + +#### LoggerRestResource.OTelAttribute class + +--- + +##### Constructors + +###### `OTelAttribute(String key, String value)` + +--- + +##### Properties + +###### `key` → `String` + +###### `value` → `OTelAttributeValue` + +--- + +#### LoggerRestResource.OTelAttributeValue class + +--- + +##### Constructors + +###### `OTelAttributeValue(String value)` + +--- + +##### Properties + +###### `stringValue` → `String` + +--- + +#### LoggerRestResource.OTelLogRecord class + +--- + +##### Properties + +###### `attributes` → `List` + +###### `body` → `OTelAttributeValue` + +###### `severityText` → `String` + +###### `timeUnixNano` → `String` + +--- + +##### Methods + +###### `getLogEntryEvent()` → `LogEntryEvent__e` + +--- + +#### LoggerRestResource.OTelLogsPayload class + +--- + +##### Properties + +###### `resourceLogs` → `List` + +--- + +##### Methods + +###### `getConvertedLogEntryEvents()` → `List` + +--- + +#### LoggerRestResource.OTelResource class + +--- + +##### Properties + +###### `attributes` → `List` + +--- + +#### LoggerRestResource.OTelResourceLog class + +--- + +##### Properties + +###### `resource` → `OTelResource` + +###### `scopeLogs` → `List` + +--- + +##### Methods + +###### `getLogEntryEvents()` → `List` + +--- + +#### LoggerRestResource.OTelScope class + +--- + +##### Properties + +###### `name` → `String` + +###### `version` → `String` + +--- + +#### LoggerRestResource.OTelScopeLog class + +--- + +##### Properties + +###### `logRecords` → `List` + +###### `scope` → `OTelScope` + +--- diff --git a/docs/apex/index.md b/docs/apex/index.md index 93b76c167..3fae8b113 100644 --- a/docs/apex/index.md +++ b/docs/apex/index.md @@ -124,6 +124,10 @@ Builds and sends email notifications when internal exceptions occur within the l Controller class for the LWC `loggerHomeHeader` +### [LoggerRestResource](Log-Management/LoggerRestResource) + +REST Resource class for external integrations to create & retrieve logging data + ### [LoggerSObjectMetadata](Log-Management/LoggerSObjectMetadata) Provides details to LWCs about Logger's `SObjects`, using `@AuraEnabled` properties diff --git a/nebula-logger/core/main/log-management/classes/LogEntryEventHandler.cls b/nebula-logger/core/main/log-management/classes/LogEntryEventHandler.cls index 676b8c962..9ecef9fc6 100644 --- a/nebula-logger/core/main/log-management/classes/LogEntryEventHandler.cls +++ b/nebula-logger/core/main/log-management/classes/LogEntryEventHandler.cls @@ -334,6 +334,7 @@ public without sharing class LogEntryEventHandler extends LoggerSObjectHandler { MessageTruncated__c = logEntryEvent.MessageTruncated__c, Name = null, // Salesforce will auto-set the record ID as the name when null OriginLocation__c = logEntryEvent.OriginLocation__c, + OriginServiceName__c = logEntryEvent.OriginServiceName__c, OriginSourceActionName__c = logEntryEvent.OriginSourceActionName__c, OriginSourceApiName__c = logEntryEvent.OriginSourceApiName__c, OriginSourceId__c = logEntryEvent.OriginSourceId__c, diff --git a/nebula-logger/core/main/log-management/classes/LogManagementDataSelector.cls b/nebula-logger/core/main/log-management/classes/LogManagementDataSelector.cls index f21da0bba..3e54077ae 100644 --- a/nebula-logger/core/main/log-management/classes/LogManagementDataSelector.cls +++ b/nebula-logger/core/main/log-management/classes/LogManagementDataSelector.cls @@ -405,7 +405,13 @@ public without sharing virtual class LogManagementDataSelector { return new List(); } - return [SELECT Id, Name, Username, SmallPhotoUrl FROM User WHERE Name LIKE :searchTerm OR Username LIKE :searchTerm ORDER BY Username LIMIT 20]; + return [ + SELECT Id, Name, Username, SmallPhotoUrl + FROM User + WHERE IsActive = TRUE AND (Name LIKE :searchTerm OR Username LIKE :searchTerm) + ORDER BY Username + LIMIT 20 + ]; } /** diff --git a/nebula-logger/core/main/log-management/classes/LoggerRestResource.cls b/nebula-logger/core/main/log-management/classes/LoggerRestResource.cls new file mode 100644 index 000000000..45a4eda15 --- /dev/null +++ b/nebula-logger/core/main/log-management/classes/LoggerRestResource.cls @@ -0,0 +1,638 @@ +//------------------------------------------------------------------------------------------------// +// This file is part of the Nebula Logger project, released under the MIT License. // +// See LICENSE file or go to https://github.com/jongpie/NebulaLogger for full license details. // +//------------------------------------------------------------------------------------------------// + +/** + * @group Log Management + * @description REST Resource class for external integrations to interact with Nebula Logger + */ + +@RestResource(urlMapping='/logger/*') +@SuppressWarnings('PMD.ApexDoc, PMD.AvoidDebugStatements, PMD.AvoidGlobalModifier, PMD.CognitiveComplexity') +global with sharing class LoggerRestResource { + @TestVisible + private static final String REQUEST_URI_BASE = '/logger'; + @TestVisible + private static final Integer STATUS_CODE_200_OK = 200; + @TestVisible + private static final Integer STATUS_CODE_201_CREATED = 201; + @TestVisible + private static final Integer STATUS_CODE_400_BAD_REQUEST = 400; + @TestVisible + private static final Integer STATUS_CODE_401_NOT_AUTHORIZED = 401; + @TestVisible + private static final Integer STATUS_CODE_404_NOT_FOUND = 404; + @TestVisible + private static final Integer STATUS_CODE_405_METHOD_NOT_ALLOWED = 405; + private static final Boolean SUPPRESS_NULLS_IN_JSON_SERIALIZATION = true; + + /** + * @description Processes any HTTP POST requests sent + */ + @HttpPost + global static void handlePost() { + // TODO wrap everything in a try-catch block + EndpointRequest endpointRequest = new EndpointRequest(System.RestContext.request); + Endpoint endpoint = getEndpoint(endpointRequest.name); + + EndpointResponse endpointResponse = endpoint.handlePost(endpointRequest); + System.RestContext.response = buildRestResponse(endpointResponse); + + logErrors(endpointRequest, endpointResponse, System.RestContext.request, System.RestContext.response); + } + + private static Endpoint getEndpoint(String endpointName) { + switch on endpointName { + when 'logs' { + return new LogsEndpoint(); + } + when else { + return new UnknownEndpointResponder(); + } + } + } + + private static System.RestResponse buildRestResponse(EndpointResponse endpointResponse) { + System.RestResponse restResponse = System.RestContext.response ?? new System.RestResponse(); + restResponse.addHeader('Content-Type', 'application/json'); + restResponse.responseBody = Blob.valueOf(System.JSON.serialize(endpointResponse, SUPPRESS_NULLS_IN_JSON_SERIALIZATION)); + restResponse.statusCode = endpointResponse.statusCode; + return restResponse; + } + + // TODO revisit - this is probably too many parameters...? + @SuppressWarnings('PMD.ExcessiveParameterList') + private static void logErrors( + EndpointRequest endpointRequest, + EndpointResponse endpointResponse, + System.RestRequest restRequest, + System.RestResponse restResponse + ) { + if (endpointResponse.isSuccess) { + return; + } + + LogMessage warningMessage = new LogMessage( + 'Inbound call to {0} endpoint failed with {1} errors:\n\n{2}', + REQUEST_URI_BASE + '/' + endpointRequest.name, + endpointResponse.errors.size(), + System.JSON.serializePretty(endpointResponse.errors) + ); + Logger.warn(warningMessage).setRestRequestDetails(restRequest).setRestResponseDetails(restResponse); + Logger.saveLog(); + } + + /* Base classes that act as the building blocks for all endpoints */ + private abstract class Endpoint { + public abstract EndpointResponse handlePost(EndpointRequest endpointRequest); + } + + @TestVisible + private class EndpointRequest { + public String body; + // public EndpointRequestContext context; + public List headerKeys; + public String httpMethod; + public String name; + public Map parameters; + public String particle; + public String uri; + + public EndpointRequest(System.RestRequest restRequest) { + String parsedName = this.getEndpointName(restRequest.requestUri); + String requestBody = restRequest.requestBody?.toString(); + + this.body = String.isBlank(requestBody) ? null : requestBody; + this.headerKeys = new List(restRequest.headers.keySet()); + this.httpMethod = restRequest.httpMethod; + this.name = parsedName; + this.parameters = restRequest.params; + this.particle = this.getEndpointParticle(restRequest.requestUri, parsedName); + this.uri = restRequest.requestUri; + } + + private String getEndpointName(String restRequestUri) { + // FIXME the comments below are no longer accurate - endpoints like /logs/ are now used + /* + Endpoint names will (at least for now) only have one layer, using formats like: + /logger/logs + /logger/logs/?some-url-parameter=true&and-another=true + /logger/something + /logger/something?another-url-parameter=something + /Nebula/logger/logs + /Nebula/logger/logs/?some-url-parameter=true&and-another=true + /Nebula/logger/something + /Nebula/logger/something?another-url-parameter=something + + The endpoint name will be just the last bit of the URL, without any parameters or '/' slashes. + So if the URL is: + /logger/something?some-url-parameter=true&and-another=true + then the endpoint name will be 'something' + */ + + String parsedEndpointName = restRequestUri.substringAfter(REQUEST_URI_BASE); + if (parsedEndpointName.contains('?')) { + parsedEndpointName = parsedEndpointName.substringBefore('?'); + } + parsedEndpointName = parsedEndpointName.removeStart('/').removeEnd('/'); + if (parsedEndpointName.contains('/')) { + parsedEndpointName = parsedEndpointName.substringBefore('/'); + } + return String.isNotBlank(parsedEndpointName) ? parsedEndpointName : null; + } + + private String getEndpointParticle(String restRequestUri, String endpointName) { + String parsedEndpointParticle = restRequestUri.substringAfter('/' + endpointName + '/'); + if (parsedEndpointParticle?.contains('?')) { + parsedEndpointParticle = parsedEndpointParticle.substringBefore('?'); + } + parsedEndpointParticle = parsedEndpointParticle.removeEnd('/'); + + return String.isBlank(parsedEndpointParticle) ? null : parsedEndpointParticle; + } + } + + @TestVisible + private class EndpointResponse { + public final List errors = new List(); + + // The status code doesn't need to be returned in the RestResponse body + // since the RestResponse headers will include the status code, so use + // 'transient' to exclude it during serialization + public transient Integer statusCode; + + public Boolean isSuccess { + get { + return this.errors.isEmpty(); + } + } + + public EndpointResponse addError(System.Exception apexException) { + return this.addError(new EndpointError(apexException)); + } + + public EndpointResponse addError(EndpointError endpointError) { + this.errors.add(endpointError); + return this; + } + + public EndpointResponse setStatusCode(Integer statusCode) { + this.statusCode = statusCode; + return this; + } + } + + @TestVisible + private virtual class EndpointError { + public final String message; + public final String type; + + public EndpointError(System.Exception apexException) { + this(apexException.getMessage(), apexException.getTypeName()); + } + + public EndpointError(String message) { + this(message, null); + } + + public EndpointError(String message, String type) { + this.message = message; + this.type = type; + } + } + + /* Endpoint implementations */ + private class LogsEndpoint extends Endpoint { + public override EndpointResponse handlePost(EndpointRequest endpointRequest) { + EndpointResponse postResponse = new EndpointResponse(); + try { + OTelLogsPayload logsPayload = this.deserializeLog(endpointRequest.body); + this.saveLog(logsPayload); + postResponse.setStatusCode(STATUS_CODE_201_CREATED); + return postResponse; + } catch (Exception apexException) { + postResponse.setStatusCode(STATUS_CODE_400_BAD_REQUEST).addError(apexException); + return postResponse; + } + } + + private void saveLog(OTelLogsPayload logsPayload) { + LoggerDataStore.getEventBus().publishRecords(logsPayload.getConvertedLogEntryEvents()); + } + + private OTelLogsPayload deserializeLog(String jsonBody) { + if (String.isBlank(jsonBody)) { + throw new System.IllegalArgumentException('No data provided'); + } + + return (OTelLogsPayload) System.JSON.deserialize(jsonBody, OTelLogsPayload.class); + } + } + + private class UnknownEndpointResponder extends Endpoint { + public override EndpointResponse handlePost(EndpointRequest endpointRequest) { + return this.handleResponse(endpointRequest); + } + + private EndpointResponse handleResponse(EndpointRequest endpointRequest) { + String errorMessage = 'Calling root endpoint /logger is not supported, please provide a specific endpoint'; + if (endpointRequest.name != null) { + errorMessage = 'Unknown endpoint provided: ' + endpointRequest.name; + } + return new EndpointResponse().setStatusCode(STATUS_CODE_404_NOT_FOUND).addError(new EndpointError(errorMessage)); + } + } + + // OpenTelemetry classes - these correspond to OTel v1.36.0's HTTP JSON format for the logs data model + // https://opentelemetry.io/docs/specs/otel/logs/data-model/ + // https://opentelemetry.io/docs/specs/otel/protocol/file-exporter/#examples + // https://github.com/open-telemetry/opentelemetry-proto/blob/main/examples/logs.json + public class OTelLogsPayload { + public final List resourceLogs = new List(); + + public List getConvertedLogEntryEvents() { + List logEntryEvents = new List(); + + for (OTelResourceLog resourceLog : this.resourceLogs) { + logEntryEvents.addAll(resourceLog.getLogEntryEvents()); + } + + return logEntryEvents; + } + } + + public class OTelResourceLog { + public final OTelResource resource = new OTelResource(); + public final List scopeLogs = new List(); + + public List getLogEntryEvents() { + List logEntryEvents = new List(); + + for (OTelScopeLog scopeLog : this.scopeLogs) { + Integer transactionEntryNumber = 1; + for (OTelLogRecord otelLogEntry : scopeLog.logRecords) { + LogEntryEvent__e convertedLogEntryEvent = otelLogEntry.getLogEntryEvent(); + convertedLogEntryEvent.TransactionEntryNumber__c = transactionEntryNumber++; + Map supplementalFieldToValue = this.resource.convertAttributes(); + for (Schema.SObjectField field : supplementalFieldToValue.keySet()) { + convertedLogEntryEvent.put(field, supplementalFieldToValue.get(field)); + } + logEntryEvents.add(convertedLogEntryEvent); + } + } + + return logEntryEvents; + } + } + + // OTel supports an additional type float64Value + // but there's not currently a need for them in Nebula Logger's data model + public class OTelAttribute { + public final String key; + public final OTelAttributeValue value; + + public OTelAttribute(String key, Boolean value) { + this.key = key; + this.value = new OTelAttributeValue(value); + } + + // public OTelAttribute(String key, Decimal value) { + // this.key = key; + // this.value = new OTelAttributeValue(value); + // } + + public OTelAttribute(String key, Integer value) { + this.key = key; + this.value = new OTelAttributeValue(value); + } + + public OTelAttribute(String key, String value) { + this.key = key; + this.value = new OTelAttributeValue(value); + } + } + + public class OTelAttributeValue { + public final Boolean boolValue; + // public final Decimal float64Value; + public final Integer intValue; + public final String stringValue; + + public OTelAttributeValue(Boolean value) { + this.boolValue = value; + } + + // public OTelAttributeValue(Decimal value) { + // this.float64Value = value; + // } + + public OTelAttributeValue(Integer value) { + this.intValue = value; + } + + public OTelAttributeValue(String value) { + this.stringValue = value; + } + } + + public class OTelResource { + public List attributes = new List(); + + private Map convertAttributes() { + Map supplementalFieldToValue = new Map(); + + for (OTelAttribute entryAttribute : this.attributes) { + switch on entryAttribute.key { + when 'service.name' { + supplementalFieldToValue.put(LogEntryEvent__e.OriginServiceName__c, entryAttribute.value?.stringValue); + } + // TODO + // when 'service.version' { + // } + } + } + + return supplementalFieldToValue; + } + } + + public class OTelScope { + public String name; + public String version; + } + + public class OTelScopeLog { + public OTelScope scope; + public List logRecords = new List(); + } + + public class OTelLogRecord { + public List attributes = new List(); + public OTelAttributeValue body; + public String name; + public Integer severityNumber; + public String severityText; + // TODO revisit mapping for spanId + public String spanId; + public String timeUnixNano; + public String traceId; + + private transient LogEntryEvent__e convertedLogEntryEvent; + + public LogEntryEvent__e getLogEntryEvent() { + if (this.convertedLogEntryEvent == null) { + System.LoggingLevel entryLoggingLevel = this.getLoggingLevel(); + Long entryEpochTimestamp = timeUnixNano == null ? null : Long.valueOf(this.timeUnixNano) / 1000000; + Datetime entryTimestamp = timeUnixNano == null ? null : Datetime.newInstance(entryEpochTimestamp); + String convertedTraceId = this.convertTraceId(); + + LogEntryEventBuilder builder = Logger.newEntry(entryLoggingLevel, this.body?.stringValue); + if (entryTimestamp != null) { + builder.setTimestamp(entryTimestamp); + } + this.convertedLogEntryEvent = builder.getLogEntryEvent(); + this.convertedLogEntryEvent.EntryScenario__c = this.name; + this.convertedLogEntryEvent.OriginType__c = 'API'; + this.convertedLogEntryEvent.TransactionId__c = convertedTraceId; + + // Since the log entries originate off-platform, tracking the limits usage isn't really relevant here + this.convertedLogEntryEvent.LimitsAggregateQueriesMax__c = null; + this.convertedLogEntryEvent.LimitsAggregateQueriesUsed__c = null; + this.convertedLogEntryEvent.LimitsAsyncCallsMax__c = null; + this.convertedLogEntryEvent.LimitsAsyncCallsUsed__c = null; + this.convertedLogEntryEvent.LimitsCalloutsMax__c = null; + this.convertedLogEntryEvent.LimitsCalloutsUsed__c = null; + this.convertedLogEntryEvent.LimitsCpuTimeMax__c = null; + this.convertedLogEntryEvent.LimitsCpuTimeUsed__c = null; + this.convertedLogEntryEvent.LimitsDmlRowsMax__c = null; + this.convertedLogEntryEvent.LimitsDmlRowsUsed__c = null; + this.convertedLogEntryEvent.LimitsDmlStatementsMax__c = null; + this.convertedLogEntryEvent.LimitsDmlStatementsUsed__c = null; + this.convertedLogEntryEvent.LimitsEmailInvocationsMax__c = null; + this.convertedLogEntryEvent.LimitsEmailInvocationsUsed__c = null; + this.convertedLogEntryEvent.LimitsFutureCallsMax__c = null; + this.convertedLogEntryEvent.LimitsFutureCallsUsed__c = null; + this.convertedLogEntryEvent.LimitsHeapSizeMax__c = null; + this.convertedLogEntryEvent.LimitsHeapSizeUsed__c = null; + this.convertedLogEntryEvent.LimitsMobilePushApexCallsMax__c = null; + this.convertedLogEntryEvent.LimitsMobilePushApexCallsUsed__c = null; + this.convertedLogEntryEvent.LimitsPublishImmediateDmlStatementsMax__c = null; + this.convertedLogEntryEvent.LimitsPublishImmediateDmlStatementsUsed__c = null; + this.convertedLogEntryEvent.LimitsQueueableJobsMax__c = null; + this.convertedLogEntryEvent.LimitsQueueableJobsUsed__c = null; + this.convertedLogEntryEvent.LimitsSoqlQueriesMax__c = null; + this.convertedLogEntryEvent.LimitsSoqlQueriesUsed__c = null; + this.convertedLogEntryEvent.LimitsSoqlQueryLocatorRowsMax__c = null; + this.convertedLogEntryEvent.LimitsSoqlQueryLocatorRowsUsed__c = null; + this.convertedLogEntryEvent.LimitsSoqlQueryRowsMax__c = null; + this.convertedLogEntryEvent.LimitsSoqlQueryRowsUsed__c = null; + this.convertedLogEntryEvent.LimitsSoslSearchesMax__c = null; + this.convertedLogEntryEvent.LimitsSoslSearchesUsed__c = null; + + // Since the log entries originate off-platform, the loggedBy user + // may not be the API user creating the logs, so clear the related fields + this.convertedLogEntryEvent.Locale__c = null; + this.convertedLogEntryEvent.LoggedById__c = null; + this.convertedLogEntryEvent.ProfileId__c = null; + this.convertedLogEntryEvent.ThemeDisplayed__c = null; + this.convertedLogEntryEvent.TimeZoneId__c = null; + this.convertedLogEntryEvent.TimeZoneName__c = null; + this.convertedLogEntryEvent.UserLicenseDefinitionKey__c = null; + this.convertedLogEntryEvent.UserLicenseId__c = null; + this.convertedLogEntryEvent.UserLicenseName__c = null; + this.convertedLogEntryEvent.UserRoleId__c = null; + this.convertedLogEntryEvent.UserRoleName__c = null; + this.convertedLogEntryEvent.UserType__c = null; + + // Clear irrelevant origin fields + this.convertedLogEntryEvent.OriginLocation__c = null; + this.convertedLogEntryEvent.OriginSourceActionName__c = null; + this.convertedLogEntryEvent.OriginSourceApiName__c = null; + this.convertedLogEntryEvent.OriginSourceId__c = null; + this.convertedLogEntryEvent.OriginSourceMetadataType__c = null; + this.convertedLogEntryEvent.StackTrace__c = null; + + Map supplementalFieldToValue = this.convertAttributes(); + for (Schema.SObjectField field : supplementalFieldToValue.keySet()) { + this.convertedLogEntryEvent.put(field, supplementalFieldToValue.get(field)); + } + } + + return this.convertedLogEntryEvent; + } + + private Map getSeverityNumberToTextMapping() { + return new Map{ + 1 => 'TRACE', + 2 => 'TRACE2', + 3 => 'TRACE3', + 4 => 'TRACE4', + 5 => 'DEBUG', + 6 => 'DEBUG2', + 7 => 'DEBUG3', + 8 => 'DEBUG4', + 9 => 'INFO', + 10 => 'INFO2', + 11 => 'INFO3', + 12 => 'INFO4', + 13 => 'WARN', + 14 => 'WARN2', + 15 => 'WARN3', + 16 => 'WARN4', + 17 => 'ERROR', + 18 => 'ERROR2', + 19 => 'ERROR3', + 20 => 'ERROR4', + 21 => 'FATAL', + 22 => 'FATAL2', + 23 => 'FATAL3', + 24 => 'FATAL4' + }; + } + + private System.LoggingLevel getLoggingLevel() { + String severityText = this.severityText ?? this.getSeverityNumberToTextMapping().get(this.severityNumber); + // Docs: https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitytext + switch on severityText?.toUpperCase() { + when 'FATAL4', 'FATAL3', 'FATAL2', 'FATAL', 'ERROR4', 'ERROR3', 'ERROR2', 'ERROR' { + return System.LoggingLevel.ERROR; + } + when 'WARN4', 'WARN3', 'WARN2', 'WARN' { + return System.LoggingLevel.WARN; + } + when 'INFO4', 'INFO3', 'INFO2', 'INFO' { + return System.LoggingLevel.INFO; + } + when 'DEBUG4', 'DEBUG3', 'DEBUG2', 'DEBUG' { + return System.LoggingLevel.DEBUG; + } + when 'TRACE4', 'TRACE3' { + return System.LoggingLevel.FINE; + } + when 'TRACE2' { + return System.LoggingLevel.FINER; + } + when 'TRACE' { + return System.LoggingLevel.FINEST; + } + when else { + // Use DEBUG as a fallback value, similar to how it's done in Logger + System.debug(System.LoggingLevel.DEBUG, 'Unable to convert severity text to logging level: ' + this.severityText); + return System.LoggingLevel.DEBUG; + } + } + } + + private Map convertAttributes() { + Map supplementalFieldToValue = new Map(); + + for (OTelAttribute entryAttribute : this.attributes) { + switch on entryAttribute.key { + when 'browser.address' { + supplementalFieldToValue.put(LogEntryEvent__e.BrowserAddress__c, entryAttribute.value?.stringValue); + } + when 'browser.form_factor' { + supplementalFieldToValue.put(LogEntryEvent__e.BrowserFormFactor__c, entryAttribute.value?.stringValue); + } + when 'browser.language' { + supplementalFieldToValue.put(LogEntryEvent__e.BrowserLanguage__c, entryAttribute.value?.stringValue); + } + when 'browser.screen_resolution' { + supplementalFieldToValue.put(LogEntryEvent__e.BrowserScreenResolution__c, entryAttribute.value?.stringValue); + } + when 'browser.user_agent' { + supplementalFieldToValue.put(LogEntryEvent__e.BrowserUserAgent__c, entryAttribute.value?.stringValue); + } + when 'browser.window_resolution' { + supplementalFieldToValue.put(LogEntryEvent__e.BrowserWindowResolution__c, entryAttribute.value?.stringValue); + } + when 'exception.message' { + supplementalFieldToValue.put(LogEntryEvent__e.ExceptionMessage__c, entryAttribute.value?.stringValue); + } + when 'exception.stack_trace' { + supplementalFieldToValue.put(LogEntryEvent__e.ExceptionStackTrace__c, entryAttribute.value?.stringValue); + } + when 'exception.type' { + supplementalFieldToValue.put(LogEntryEvent__e.ExceptionType__c, entryAttribute.value?.stringValue); + } + when 'http_request.body' { + supplementalFieldToValue.put(LogEntryEvent__e.HttpRequestBody__c, entryAttribute.value?.stringValue); + } + when 'http_request.body_masked' { + supplementalFieldToValue.put(LogEntryEvent__e.HttpRequestBodyMasked__c, entryAttribute.value?.boolValue); + } + when 'http_request.compressed' { + supplementalFieldToValue.put(LogEntryEvent__e.HttpRequestCompressed__c, entryAttribute.value?.boolValue); + } + when 'http_request.endpoint' { + supplementalFieldToValue.put(LogEntryEvent__e.HttpRequestEndpoint__c, entryAttribute.value?.stringValue); + } + when 'http_request.header_keys' { + supplementalFieldToValue.put(LogEntryEvent__e.HttpRequestHeaderKeys__c, entryAttribute.value?.stringValue); + } + when 'http_request.headers' { + supplementalFieldToValue.put(LogEntryEvent__e.HttpRequestHeaders__c, entryAttribute.value?.stringValue); + } + when 'http_request.method' { + supplementalFieldToValue.put(LogEntryEvent__e.HttpRequestMethod__c, entryAttribute.value?.stringValue); + } + when 'http_response.body' { + supplementalFieldToValue.put(LogEntryEvent__e.HttpResponseBody__c, entryAttribute.value?.stringValue); + } + when 'http_response.body_masked' { + supplementalFieldToValue.put(LogEntryEvent__e.HttpResponseBodyMasked__c, entryAttribute.value?.boolValue); + } + when 'http_response.header_keys' { + supplementalFieldToValue.put(LogEntryEvent__e.HttpResponseHeaderKeys__c, entryAttribute.value?.stringValue); + } + when 'http_response.headers' { + supplementalFieldToValue.put(LogEntryEvent__e.HttpResponseHeaders__c, entryAttribute.value?.stringValue); + } + when 'http_response.status' { + supplementalFieldToValue.put(LogEntryEvent__e.HttpResponseStatus__c, entryAttribute.value?.stringValue); + } + when 'http_response.status_code' { + supplementalFieldToValue.put(LogEntryEvent__e.HttpResponseStatusCode__c, entryAttribute.value?.intValue); + } + when 'logged_by.federation_identifier' { + supplementalFieldToValue.put(LogEntryEvent__e.LoggedByFederationIdentifier__c, entryAttribute.value?.stringValue); + } + when 'logged_by.id' { + supplementalFieldToValue.put(LogEntryEvent__e.LoggedById__c, entryAttribute.value?.stringValue); + } + when 'logged_by.username' { + supplementalFieldToValue.put(LogEntryEvent__e.LoggedByUsername__c, entryAttribute.value?.stringValue); + } + when 'origin.stack_trace' { + supplementalFieldToValue.put(LogEntryEvent__e.StackTrace__c, entryAttribute.value?.stringValue); + } + when 'parent_log.transaction_id' { + supplementalFieldToValue.put(LogEntryEvent__e.ParentLogTransactionId__c, entryAttribute.value?.stringValue); + } + } + } + + return supplementalFieldToValue; + } + + private String convertTraceId() { + if (String.isBlank(this.traceId)) { + return null; + } + + String hyphenatedUuid = + this.traceId.substring(0, 8) + + '-' + + this.traceId.substring(8, 12) + + '-' + + this.traceId.substring(12, 16) + + '-' + + this.traceId.substring(16, 20) + + '-' + + this.traceId.substring(20, 32); + + return hyphenatedUuid; + } + } +} diff --git a/nebula-logger/core/main/log-management/classes/LoggerRestResource.cls-meta.xml b/nebula-logger/core/main/log-management/classes/LoggerRestResource.cls-meta.xml new file mode 100644 index 000000000..c01f6433a --- /dev/null +++ b/nebula-logger/core/main/log-management/classes/LoggerRestResource.cls-meta.xml @@ -0,0 +1,5 @@ + + + 61.0 + Active + diff --git a/nebula-logger/core/main/log-management/objects/LogEntry__c/fields/OriginServiceName__c.field-meta.xml b/nebula-logger/core/main/log-management/objects/LogEntry__c/fields/OriginServiceName__c.field-meta.xml new file mode 100644 index 000000000..993b8562b --- /dev/null +++ b/nebula-logger/core/main/log-management/objects/LogEntry__c/fields/OriginServiceName__c.field-meta.xml @@ -0,0 +1,14 @@ + + + OriginServiceName__c + Active + None + true + + 255 + false + Confidential + false + Text + false + diff --git a/nebula-logger/core/main/log-management/objects/LogEntry__c/fields/OriginType__c.field-meta.xml b/nebula-logger/core/main/log-management/objects/LogEntry__c/fields/OriginType__c.field-meta.xml index 94c54452f..0cf6fcf4f 100644 --- a/nebula-logger/core/main/log-management/objects/LogEntry__c/fields/OriginType__c.field-meta.xml +++ b/nebula-logger/core/main/log-management/objects/LogEntry__c/fields/OriginType__c.field-meta.xml @@ -13,25 +13,31 @@ false Apex - #333333 + #FF595E false + + API + #FFCA3A + false + + Component - #A845DC + #8AC926 false Flow - #FFCC33 + #1982C4 false OmniStudio - #FFCC33 + #6A4C93 false diff --git a/nebula-logger/core/main/log-management/objects/LogEntry__c/listViews/AllApiLogEntries.listView-meta.xml b/nebula-logger/core/main/log-management/objects/LogEntry__c/listViews/AllApiLogEntries.listView-meta.xml new file mode 100644 index 000000000..5e21d4a88 --- /dev/null +++ b/nebula-logger/core/main/log-management/objects/LogEntry__c/listViews/AllApiLogEntries.listView-meta.xml @@ -0,0 +1,18 @@ + + + AllApiLogEntries + NAME + Log__c + LoggingLevel__c + LoggedByUsernameLink__c + Message__c + OriginServiceName__c + Timestamp__c + Everything + + OriginType__c + equals + API + + + diff --git a/nebula-logger/core/main/log-management/permissionsets/LoggerAdmin.permissionset-meta.xml b/nebula-logger/core/main/log-management/permissionsets/LoggerAdmin.permissionset-meta.xml index aa85c701f..5e018d1d9 100644 --- a/nebula-logger/core/main/log-management/permissionsets/LoggerAdmin.permissionset-meta.xml +++ b/nebula-logger/core/main/log-management/permissionsets/LoggerAdmin.permissionset-meta.xml @@ -64,6 +64,10 @@ Logger true + + LoggerRestResource + true + LoggerHomeHeaderController true @@ -906,6 +910,11 @@ LogEntry__c.OriginLocation__c true + + false + LogEntry__c.OriginServiceName__c + true + false LogEntry__c.OriginSourceActionName__c diff --git a/nebula-logger/core/main/log-management/permissionsets/LoggerEndUser.permissionset-meta.xml b/nebula-logger/core/main/log-management/permissionsets/LoggerEndUser.permissionset-meta.xml index cdaad82f0..83ceb3d8b 100644 --- a/nebula-logger/core/main/log-management/permissionsets/LoggerEndUser.permissionset-meta.xml +++ b/nebula-logger/core/main/log-management/permissionsets/LoggerEndUser.permissionset-meta.xml @@ -597,6 +597,11 @@ LogEntry__c.OriginLocation__c true + + false + LogEntry__c.OriginServiceName__c + true + false LogEntry__c.OriginSourceApiName__c diff --git a/nebula-logger/core/main/log-management/permissionsets/LoggerLogViewer.permissionset-meta.xml b/nebula-logger/core/main/log-management/permissionsets/LoggerLogViewer.permissionset-meta.xml index f226455a8..7553bb645 100644 --- a/nebula-logger/core/main/log-management/permissionsets/LoggerLogViewer.permissionset-meta.xml +++ b/nebula-logger/core/main/log-management/permissionsets/LoggerLogViewer.permissionset-meta.xml @@ -826,6 +826,11 @@ LogEntry__c.OriginLocation__c true + + false + LogEntry__c.OriginServiceName__c + true + false LogEntry__c.OriginSourceActionName__c diff --git a/nebula-logger/core/main/log-management/permissionsets/LoggerRestIntegration.permissionset-meta.xml b/nebula-logger/core/main/log-management/permissionsets/LoggerRestIntegration.permissionset-meta.xml new file mode 100644 index 000000000..3c5876720 --- /dev/null +++ b/nebula-logger/core/main/log-management/permissionsets/LoggerRestIntegration.permissionset-meta.xml @@ -0,0 +1,10 @@ + + + + LoggerRestResource + true + + Provides access to integrate with Nebula Logger via REST API calls + false + + diff --git a/nebula-logger/core/main/logger-engine/classes/Logger.cls b/nebula-logger/core/main/logger-engine/classes/Logger.cls index 9fa54941a..2d0b82c62 100644 --- a/nebula-logger/core/main/logger-engine/classes/Logger.cls +++ b/nebula-logger/core/main/logger-engine/classes/Logger.cls @@ -15,7 +15,7 @@ global with sharing class Logger { // There's no reliable way to get the version number dynamically in Apex @TestVisible - private static final String CURRENT_VERSION_NUMBER = 'v4.14.10'; + private static final String CURRENT_VERSION_NUMBER = 'v4.14.11'; private static final System.LoggingLevel FALLBACK_LOGGING_LEVEL = System.LoggingLevel.DEBUG; private static final List LOG_ENTRIES_BUFFER = new List(); private static final String MISSING_SCENARIO_ERROR_MESSAGE = 'No logger scenario specified. A scenario is required for logging in this org.'; diff --git a/nebula-logger/core/main/logger-engine/lwc/logger/logEntryBuilder.js b/nebula-logger/core/main/logger-engine/lwc/logger/logEntryBuilder.js index 1150bda94..2a964b65b 100644 --- a/nebula-logger/core/main/logger-engine/lwc/logger/logEntryBuilder.js +++ b/nebula-logger/core/main/logger-engine/lwc/logger/logEntryBuilder.js @@ -6,7 +6,7 @@ import FORM_FACTOR from '@salesforce/client/formFactor'; import { log as lightningLog } from 'lightning/logger'; import { LoggerStackTrace } from './loggerStackTrace'; -const CURRENT_VERSION_NUMBER = 'v4.14.10'; +const CURRENT_VERSION_NUMBER = 'v4.14.11'; const LOGGING_LEVEL_EMOJIS = { ERROR: '⛔', diff --git a/nebula-logger/core/main/logger-engine/objects/LogEntryEvent__e/fields/OriginServiceName__c.field-meta.xml b/nebula-logger/core/main/logger-engine/objects/LogEntryEvent__e/fields/OriginServiceName__c.field-meta.xml new file mode 100644 index 000000000..653db4a0e --- /dev/null +++ b/nebula-logger/core/main/logger-engine/objects/LogEntryEvent__e/fields/OriginServiceName__c.field-meta.xml @@ -0,0 +1,16 @@ + + + OriginServiceName__c + Active + None + false + false + false + false + + 255 + false + Confidential + Text + false + diff --git a/nebula-logger/core/tests/LoggerCore.testSuite-meta.xml b/nebula-logger/core/tests/LoggerCore.testSuite-meta.xml index e456428ab..3e29af43c 100644 --- a/nebula-logger/core/tests/LoggerCore.testSuite-meta.xml +++ b/nebula-logger/core/tests/LoggerCore.testSuite-meta.xml @@ -24,6 +24,7 @@ LoggerHomeHeaderController_Tests LoggerParameter_Tests LoggerPlugin_Tests + LoggerRestResource_Tests LoggerScenarioHandler_Tests LoggerScenarioRule_Tests LoggerSettingsController_Tests diff --git a/nebula-logger/core/tests/log-management/classes/LogEntryEventHandler_Tests.cls b/nebula-logger/core/tests/log-management/classes/LogEntryEventHandler_Tests.cls index fa4e48474..18553469d 100644 --- a/nebula-logger/core/tests/log-management/classes/LogEntryEventHandler_Tests.cls +++ b/nebula-logger/core/tests/log-management/classes/LogEntryEventHandler_Tests.cls @@ -1435,6 +1435,7 @@ private class LogEntryEventHandler_Tests { Id, Name, OriginLocation__c, + OriginServiceName__c, OriginSourceActionName__c, OriginSourceApiName__c, OriginSourceId__c, @@ -1755,6 +1756,7 @@ private class LogEntryEventHandler_Tests { logEntry.OriginSourceMetadataType__c, 'logEntry.OriginSourceMetadataType__c was not properly set' ); + System.Assert.areEqual(logEntryEvent.OriginServiceName__c, logEntry.OriginServiceName__c, 'logEntry.OriginServiceName__c was not properly set'); System.Assert.areEqual(logEntryEvent.OriginType__c, logEntry.OriginType__c, 'logEntry.OriginType__c was not properly set'); System.Assert.areEqual(logEntryEvent.RecordCollectionSize__c, logEntry.RecordCollectionSize__c, 'logEntry.RecordCollectionSize__c was not properly set'); System.Assert.areEqual(logEntryEvent.RecordCollectionType__c, logEntry.RecordCollectionType__c, 'logEntry.RecordCollectionType__c was not properly set'); diff --git a/nebula-logger/core/tests/log-management/classes/LogManagementDataSelector_Tests.cls b/nebula-logger/core/tests/log-management/classes/LogManagementDataSelector_Tests.cls index b0252745a..8e7f7838c 100644 --- a/nebula-logger/core/tests/log-management/classes/LogManagementDataSelector_Tests.cls +++ b/nebula-logger/core/tests/log-management/classes/LogManagementDataSelector_Tests.cls @@ -546,7 +546,7 @@ private class LogManagementDataSelector_Tests { List expectedResults = [ SELECT Id, Name, Username, SmallPhotoUrl FROM User - WHERE Name LIKE :searchTerm OR Username LIKE :searchTerm + WHERE IsActive = TRUE AND (Name LIKE :searchTerm OR Username LIKE :searchTerm) ]; List returnedResults = LogManagementDataSelector.getInstance().getUsersByNameSearch(searchTerm); diff --git a/nebula-logger/core/tests/log-management/classes/LoggerRestResource_Tests.cls b/nebula-logger/core/tests/log-management/classes/LoggerRestResource_Tests.cls new file mode 100644 index 000000000..3eddef234 --- /dev/null +++ b/nebula-logger/core/tests/log-management/classes/LoggerRestResource_Tests.cls @@ -0,0 +1,361 @@ +//------------------------------------------------------------------------------------------------// +// This file is part of the Nebula Logger project, released under the MIT License. // +// See LICENSE file or go to https://github.com/jongpie/NebulaLogger for full license details. // +//------------------------------------------------------------------------------------------------// + +@SuppressWarnings('PMD.ApexDoc, PMD.MethodNamingConventions, PMD.NcssMethodCount') +@IsTest(IsParallel=true) +private class LoggerRestResource_Tests { + @IsTest + static void endpoint_request_correctly_parses_system_rest_request_without_endpoint_particle() { + String expectedEndpointName = 'some-endpoint-name'; + String expectedRequestBody = 'some string that may or may not be valid JSON (but hopefully it is)'; + System.RestRequest restRequest = new System.RestRequest(); + restRequest.addHeader('X-some-header', 'some-value'); + restRequest.addHeader('X-another-header', 'another-value'); + restRequest.addParameter('verbose', 'true'); + restRequest.addParameter('some-other-parameter', 'someValue'); + restRequest.requestBody = Blob.valueOf(expectedRequestBody); + restRequest.requestUri = LoggerRestResource.REQUEST_URI_BASE + '/' + expectedEndpointName + '/'; + + LoggerRestResource.EndpointRequest endpointRequest = new LoggerRestResource.EndpointRequest(restRequest); + + System.Assert.areEqual(expectedRequestBody, endpointRequest.body); + System.Assert.areEqual(expectedEndpointName, endpointRequest.name); + System.Assert.isNull(endpointRequest.particle); + System.Assert.areEqual(new List(restRequest.headers.keySet()), endpointRequest.headerKeys); + System.Assert.areEqual(restRequest.params, endpointRequest.parameters); + System.Assert.areEqual(restRequest.requestUri, endpointRequest.uri); + } + + @IsTest + static void endpoint_request_correctly_parses_system_rest_request_with_endpoint_particle() { + String expectedEndpointName = 'some-endpoint-name'; + String expectedEndpointParticle = System.UUID.randomUUID().toString(); + String expectedRequestBody = 'some string that may or may not be valid JSON (but hopefully it is)'; + System.RestRequest restRequest = new System.RestRequest(); + restRequest.addHeader('X-some-header', 'some-value'); + restRequest.addHeader('X-another-header', 'another-value'); + restRequest.addParameter('verbose', 'true'); + restRequest.addParameter('some-other-parameter', 'someValue'); + restRequest.requestBody = Blob.valueOf(expectedRequestBody); + restRequest.requestUri = LoggerRestResource.REQUEST_URI_BASE + '/' + expectedEndpointName + '/' + expectedEndpointParticle; + + LoggerRestResource.EndpointRequest endpointRequest = new LoggerRestResource.EndpointRequest(restRequest); + + System.Assert.areEqual(expectedRequestBody, endpointRequest.body); + System.Assert.areEqual(expectedEndpointName, endpointRequest.name); + System.Assert.areEqual(expectedEndpointParticle, endpointRequest.particle); + System.Assert.areEqual(new List(restRequest.headers.keySet()), endpointRequest.headerKeys); + System.Assert.areEqual(restRequest.params, endpointRequest.parameters); + System.Assert.areEqual(restRequest.requestUri, endpointRequest.uri); + } + + @IsTest + static void unknown_endpoint_post_throws_an_exception() { + String unknownEndpoint = 'some-endpoint-that-definitely-should-not-exist'; + String someParameters = '/?i-hope=true'; + System.RestContext.request = new System.RestRequest(); + System.RestContext.request.requestUri = LoggerRestResource.REQUEST_URI_BASE + '/' + unknownEndpoint + someParameters; + + LoggerRestResource.handlePost(); + + System.Assert.areEqual(404, System.RestContext.response.statusCode); + System.Assert.areEqual('application/json', System.RestContext.response.headers.get('Content-Type')); + System.Assert.isNotNull(System.RestContext.response.responseBody); + LoggerRestResource.EndpointResponse endpointResponse = (LoggerRestResource.EndpointResponse) System.JSON.deserialize( + System.RestContext.response.responseBody.toString(), + LoggerRestResource.EndpointResponse.class + ); + System.Assert.isFalse(endpointResponse.isSuccess); + System.Assert.areEqual(1, endpointResponse.errors.size()); + System.Assert.areEqual('Unknown endpoint provided: ' + unknownEndpoint, endpointResponse.errors.get(0).message); + } + + @IsTest + static void otel_severity_text_correctly_maps_to_logging_level() { + Map otelSeverityTextToExpectedLoggingLevel = new Map{ + 'Error' => System.LoggingLevel.ERROR, + 'Warn' => System.LoggingLevel.WARN, + 'Info' => System.LoggingLevel.INFO, + 'Debug' => System.LoggingLevel.DEBUG, + 'Trace3' => System.LoggingLevel.FINE, + 'Trace2' => System.LoggingLevel.FINER, + 'Trace' => System.LoggingLevel.FINEST, + 'Anything else' => System.LoggingLevel.DEBUG + }; + for (String otelSeverityText : otelSeverityTextToExpectedLoggingLevel.keySet()) { + LoggerRestResource.OTelLogRecord otelLogEntry = new LoggerRestResource.OTelLogRecord(); + + otelLogEntry.severityText = otelSeverityText; + + System.LoggingLevel expectedLoggingLevel = otelSeverityTextToExpectedLoggingLevel.get(otelSeverityText); + System.Assert.areEqual(expectedLoggingLevel.name(), otelLogEntry.getLogEntryEvent().LoggingLevel__c); + System.Assert.areEqual(expectedLoggingLevel.ordinal(), otelLogEntry.getLogEntryEvent().LoggingLevelOrdinal__c); + } + } + + @IsTest + static void logs_endpoint_post_throws_an_exception_when_null_log_entries_list_is_provided() { + System.RestContext.request = new System.RestRequest(); + System.RestContext.request.requestBody = null; + System.RestContext.request.requestUri = LoggerRestResource.REQUEST_URI_BASE + '/logs'; + + LoggerRestResource.handlePost(); + + System.Assert.areEqual(LoggerRestResource.STATUS_CODE_400_BAD_REQUEST, System.RestContext.response.statusCode); + System.Assert.areEqual('application/json', System.RestContext.response.headers.get('Content-Type')); + System.Assert.isNotNull(System.RestContext.response.responseBody); + LoggerRestResource.EndpointResponse endpointResponse = (LoggerRestResource.EndpointResponse) System.JSON.deserialize( + System.RestContext.response.responseBody.toString(), + LoggerRestResource.EndpointResponse.class + ); + System.Assert.isFalse(endpointResponse.isSuccess); + System.Assert.areEqual(1, endpointResponse.errors.size()); + System.Assert.areEqual('No data provided', endpointResponse.errors.get(0).message); + System.Assert.areEqual(System.IllegalArgumentException.class.getName(), endpointResponse.errors.get(0).type); + } + + @IsTest + static void logs_endpoint_post_successsfully_saves_otel_log_when_data_is_provided() { + // This test method is... incredibly long. The work is "simple", it's "just" creating a bunch of OTel attributes + // and validating that they map to the correct LogEntryEvent__e fields. But the lines of code is a lot... + // TODO revisit to see if there's a way to shorten this up/make it more readable + LoggerDataStore.setMock(LoggerMockDataStore.getEventBus()); + System.Assert.areEqual(0, LoggerMockDataStore.getEventBus().getPublishCallCount()); + System.Assert.areEqual(0, LoggerMockDataStore.getEventBus().getPublishedPlatformEvents().size()); + LoggerRestResource.OTelLogRecord otelLogEntry = new LoggerRestResource.OTelLogRecord(); + otelLogEntry.body = new LoggerRestResource.OTelAttributeValue('some message'); + otelLogEntry.name = 'Some span name, which maps to Nebula Logger\'s scenario name'; + otelLogEntry.severityText = 'Info'; + // otelLogEntry.spanId = 'TODO'; + otelLogEntry.timeUnixNano = (System.now().getTime() * 1000000).toString(); + otelLogEntry.traceId = System.UUID.randomUUID().toString().replace('-', '').toLowerCase(); + LoggerRestResource.OTelAttribute browserAddressAttribute = new LoggerRestResource.OTelAttribute('browser.address', 'some browser address'); + otelLogEntry.attributes.add(browserAddressAttribute); + LoggerRestResource.OTelAttribute browserFormFactorAttribute = new LoggerRestResource.OTelAttribute('browser.form_factor', 'some browser form factor'); + otelLogEntry.attributes.add(browserFormFactorAttribute); + LoggerRestResource.OTelAttribute browserLanguageAttribute = new LoggerRestResource.OTelAttribute('browser.language', 'some browser language'); + otelLogEntry.attributes.add(browserLanguageAttribute); + LoggerRestResource.OTelAttribute browserScreenResolutionAttribute = new LoggerRestResource.OTelAttribute( + 'browser.screen_resolution', + 'some browser screen resolution' + ); + otelLogEntry.attributes.add(browserScreenResolutionAttribute); + LoggerRestResource.OTelAttribute browserUserAgentAttribute = new LoggerRestResource.OTelAttribute('browser.user_agent', 'some browser user agent'); + otelLogEntry.attributes.add(browserUserAgentAttribute); + LoggerRestResource.OTelAttribute browserWindowResolutionAttribute = new LoggerRestResource.OTelAttribute( + 'browser.window_resolution', + 'some browser window resolution' + ); + otelLogEntry.attributes.add(browserWindowResolutionAttribute); + LoggerRestResource.OTelAttribute exceptionMessageAttribute = new LoggerRestResource.OTelAttribute('exception.message', 'some exception message'); + otelLogEntry.attributes.add(exceptionMessageAttribute); + LoggerRestResource.OTelAttribute exceptionStackTraceAttribute = new LoggerRestResource.OTelAttribute('exception.stack_trace', 'Some.Exception.stackTrace'); + otelLogEntry.attributes.add(exceptionStackTraceAttribute); + LoggerRestResource.OTelAttribute exceptionTypeAttribute = new LoggerRestResource.OTelAttribute('exception.type', 'SomeExceptionType'); + otelLogEntry.attributes.add(exceptionTypeAttribute); + LoggerRestResource.OTelAttribute httpRequestBodyAttribute = new LoggerRestResource.OTelAttribute('http_request.body', 'some value for http_request.body'); + otelLogEntry.attributes.add(httpRequestBodyAttribute); + LoggerRestResource.OTelAttribute httpRequestBodyMaskedAttribute = new LoggerRestResource.OTelAttribute('http_request.body_masked', true); + otelLogEntry.attributes.add(httpRequestBodyMaskedAttribute); + LoggerRestResource.OTelAttribute httpRequestCompressedAttribute = new LoggerRestResource.OTelAttribute('http_request.compressed', false); + otelLogEntry.attributes.add(httpRequestCompressedAttribute); + LoggerRestResource.OTelAttribute httpRequestEndpointAttribute = new LoggerRestResource.OTelAttribute( + 'http_request.endpoint', + 'some value for http_request.endpoint' + ); + otelLogEntry.attributes.add(httpRequestEndpointAttribute); + LoggerRestResource.OTelAttribute httpRequestHeaderKeysAttribute = new LoggerRestResource.OTelAttribute( + 'http_request.header_keys', + 'some value for http_request.header_keys' + ); + otelLogEntry.attributes.add(httpRequestHeaderKeysAttribute); + LoggerRestResource.OTelAttribute httpRequestHeadersAttribute = new LoggerRestResource.OTelAttribute( + 'http_request.headers', + 'some value for http_request.headers' + ); + otelLogEntry.attributes.add(httpRequestHeadersAttribute); + LoggerRestResource.OTelAttribute httpRequestMethodAttribute = new LoggerRestResource.OTelAttribute( + 'http_request.method', + 'some value for http_request.method' + ); + otelLogEntry.attributes.add(httpRequestMethodAttribute); + LoggerRestResource.OTelAttribute httpResponseBodyAttribute = new LoggerRestResource.OTelAttribute( + 'http_response.body', + 'some value for http_response.body' + ); + otelLogEntry.attributes.add(httpResponseBodyAttribute); + LoggerRestResource.OTelAttribute httpResponseBodyMaskedAttribute = new LoggerRestResource.OTelAttribute('http_response.body_masked', true); + otelLogEntry.attributes.add(httpResponseBodyMaskedAttribute); + LoggerRestResource.OTelAttribute httpResponseHeaderKeysAttribute = new LoggerRestResource.OTelAttribute( + 'http_response.header_keys', + 'some value for http_response.header_keys' + ); + otelLogEntry.attributes.add(httpResponseHeaderKeysAttribute); + LoggerRestResource.OTelAttribute httpResponseHeadersAttribute = new LoggerRestResource.OTelAttribute( + 'http_response.headers', + 'some value for http_response.headers' + ); + otelLogEntry.attributes.add(httpResponseHeadersAttribute); + LoggerRestResource.OTelAttribute httpResponseStatusAttribute = new LoggerRestResource.OTelAttribute( + 'http_response.status', + 'some value for http_response.status' + ); + otelLogEntry.attributes.add(httpResponseStatusAttribute); + LoggerRestResource.OTelAttribute httpResponseStatusCodeAttribute = new LoggerRestResource.OTelAttribute('http_response.status_code', 123); + otelLogEntry.attributes.add(httpResponseStatusCodeAttribute); + LoggerRestResource.OTelAttribute loggedByFederationIdentifierAttribute = new LoggerRestResource.OTelAttribute( + 'logged_by.federation_identifier', + 'Some.Federation.Identifier@saml.system.sso.com.org' + ); + otelLogEntry.attributes.add(loggedByFederationIdentifierAttribute); + LoggerRestResource.OTelAttribute loggedByIdAttribute = new LoggerRestResource.OTelAttribute( + 'logged_by.id', + LoggerMockDataCreator.createId(Schema.User.SObjectType) + ); + otelLogEntry.attributes.add(loggedByIdAttribute); + LoggerRestResource.OTelAttribute loggedByUsernameAttribute = new LoggerRestResource.OTelAttribute('logged_by.username', 'Some.Username@some.company.com'); + otelLogEntry.attributes.add(loggedByUsernameAttribute); + LoggerRestResource.OTelAttribute originStackTraceAttribute = new LoggerRestResource.OTelAttribute('origin.stack_trace', 'Some.Origin.stackTrace'); + otelLogEntry.attributes.add(originStackTraceAttribute); + LoggerRestResource.OTelAttribute parentLogTransactionIdAttribute = new LoggerRestResource.OTelAttribute('parent_log.transaction_id', '123-abc'); + otelLogEntry.attributes.add(parentLogTransactionIdAttribute); + LoggerRestResource.OTelScopeLog scopeLog = new LoggerRestResource.OTelScopeLog(); + scopeLog.logRecords.add(otelLogEntry); + LoggerRestResource.OTelResourceLog resourceLog = new LoggerRestResource.OTelResourceLog(); + LoggerRestResource.OTelAttribute resourceServiceNameAttribute = new LoggerRestResource.OTelAttribute( + 'service.name', + 'some-external-system-or-microservice' + ); + resourceLog.resource.attributes.add(resourceServiceNameAttribute); + resourceLog.scopeLogs.add(scopeLog); + LoggerRestResource.OTelLogsPayload logsPayload = new LoggerRestResource.OTelLogsPayload(); + logsPayload.resourceLogs.add(resourceLog); + System.RestContext.request = new System.RestRequest(); + System.RestContext.request.requestBody = Blob.valueOf(System.JSON.serialize(logsPayload)); + System.RestContext.request.requestUri = LoggerRestResource.REQUEST_URI_BASE + '/logs'; + + LoggerRestResource.handlePost(); + + System.Assert.areEqual( + LoggerRestResource.STATUS_CODE_201_CREATED, + System.RestContext.response.statusCode, + System.RestContext.response.responseBody.toString() + ); + System.Assert.areEqual('application/json', System.RestContext.response.headers.get('Content-Type')); + System.Assert.isNotNull(System.RestContext.response.responseBody); + LoggerRestResource.EndpointResponse endpointResponse = (LoggerRestResource.EndpointResponse) System.JSON.deserialize( + System.RestContext.response.responseBody.toString(), + LoggerRestResource.EndpointResponse.class + ); + System.Assert.isTrue(endpointResponse.isSuccess); + System.Assert.areEqual(0, endpointResponse.errors.size()); + System.Assert.areEqual(1, LoggerMockDataStore.getEventBus().getPublishCallCount()); + System.Assert.areEqual(1, LoggerMockDataStore.getEventBus().getPublishedPlatformEvents().size()); + LogEntryEvent__e publishedLogEntryEvent = (LogEntryEvent__e) LoggerMockDataStore.getEventBus().getPublishedPlatformEvents().get(0); + // Origin Fields + System.Assert.isNull(publishedLogEntryEvent.OriginLocation__c); + System.Assert.areEqual(resourceServiceNameAttribute.value.stringValue, publishedLogEntryEvent.OriginServiceName__c); + System.Assert.isNull(publishedLogEntryEvent.OriginSourceActionName__c); + System.Assert.isNull(publishedLogEntryEvent.OriginSourceApiName__c); + System.Assert.isNull(publishedLogEntryEvent.OriginSourceId__c); + System.Assert.isNull(publishedLogEntryEvent.OriginSourceMetadataType__c); + System.Assert.areEqual('API', publishedLogEntryEvent.OriginType__c); + System.Assert.areEqual(otelLogEntry.severityText.toUpperCase(), publishedLogEntryEvent.LoggingLevel__c); + System.Assert.areEqual(otelLogEntry.body.stringValue, publishedLogEntryEvent.Message__c); + // Limits Fields + System.Assert.isNull(publishedLogEntryEvent.LimitsAggregateQueriesMax__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsAggregateQueriesUsed__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsAsyncCallsMax__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsAsyncCallsUsed__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsCalloutsMax__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsCalloutsUsed__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsCpuTimeMax__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsCpuTimeUsed__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsDmlRowsMax__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsDmlRowsUsed__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsDmlStatementsMax__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsDmlStatementsUsed__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsEmailInvocationsMax__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsEmailInvocationsUsed__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsFutureCallsMax__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsFutureCallsUsed__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsHeapSizeMax__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsHeapSizeUsed__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsMobilePushApexCallsMax__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsMobilePushApexCallsUsed__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsPublishImmediateDmlStatementsMax__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsPublishImmediateDmlStatementsUsed__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsQueueableJobsMax__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsQueueableJobsUsed__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsSoqlQueriesMax__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsSoqlQueriesUsed__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsSoqlQueryLocatorRowsMax__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsSoqlQueryLocatorRowsUsed__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsSoqlQueryRowsMax__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsSoqlQueryRowsUsed__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsSoslSearchesMax__c); + System.Assert.isNull(publishedLogEntryEvent.LimitsSoslSearchesUsed__c); + // User Fields (that should not be auto-populated) + System.Assert.isNull(publishedLogEntryEvent.Locale__c); + System.Assert.isNull(publishedLogEntryEvent.ProfileId__c); + System.Assert.isNull(publishedLogEntryEvent.ThemeDisplayed__c); + System.Assert.isNull(publishedLogEntryEvent.TimeZoneId__c); + System.Assert.isNull(publishedLogEntryEvent.TimeZoneName__c); + System.Assert.isNull(publishedLogEntryEvent.UserLicenseDefinitionKey__c); + System.Assert.isNull(publishedLogEntryEvent.UserLicenseId__c); + System.Assert.isNull(publishedLogEntryEvent.UserLicenseName__c); + System.Assert.isNull(publishedLogEntryEvent.UserRoleId__c); + System.Assert.isNull(publishedLogEntryEvent.UserRoleName__c); + System.Assert.isNull(publishedLogEntryEvent.UserType__c); + // Browser Fields + System.Assert.areEqual(browserAddressAttribute.value.stringValue, publishedLogEntryEvent.BrowserAddress__c); + System.Assert.areEqual(browserFormFactorAttribute.value.stringValue, publishedLogEntryEvent.BrowserFormFactor__c); + System.Assert.areEqual(browserLanguageAttribute.value.stringValue, publishedLogEntryEvent.BrowserLanguage__c); + System.Assert.areEqual(browserScreenResolutionAttribute.value.stringValue, publishedLogEntryEvent.BrowserScreenResolution__c); + System.Assert.areEqual(browserUserAgentAttribute.value.stringValue, publishedLogEntryEvent.BrowserUserAgent__c); + System.Assert.areEqual(browserWindowResolutionAttribute.value.stringValue, publishedLogEntryEvent.BrowserWindowResolution__c); + // Exception Fields + System.Assert.areEqual(exceptionMessageAttribute.value.stringValue, publishedLogEntryEvent.ExceptionMessage__c); + System.Assert.areEqual(exceptionStackTraceAttribute.value.stringValue, publishedLogEntryEvent.ExceptionStackTrace__c); + System.Assert.areEqual(exceptionTypeAttribute.value.stringValue, publishedLogEntryEvent.ExceptionType__c); + // HTTP Request Fields + System.Assert.areEqual(httpRequestBodyMaskedAttribute.value.boolValue, publishedLogEntryEvent.HttpRequestBodyMasked__c); + System.Assert.areEqual(httpRequestCompressedAttribute.value.boolValue, publishedLogEntryEvent.HttpRequestCompressed__c); + System.Assert.areEqual(httpRequestEndpointAttribute.value.stringValue, publishedLogEntryEvent.HttpRequestEndpoint__c); + System.Assert.areEqual(httpRequestHeaderKeysAttribute.value.stringValue, publishedLogEntryEvent.HttpRequestHeaderKeys__c); + System.Assert.areEqual(httpRequestHeadersAttribute.value.stringValue, publishedLogEntryEvent.HttpRequestHeaders__c); + System.Assert.areEqual(httpRequestMethodAttribute.value.stringValue, publishedLogEntryEvent.HttpRequestMethod__c); + System.Assert.areEqual(httpResponseBodyAttribute.value.stringValue, publishedLogEntryEvent.HttpResponseBody__c); + // HTTP Response Fields + System.Assert.areEqual(httpResponseBodyMaskedAttribute.value.boolValue, publishedLogEntryEvent.HttpResponseBodyMasked__c); + System.Assert.areEqual(httpResponseHeaderKeysAttribute.value.stringValue, publishedLogEntryEvent.HttpResponseHeaderKeys__c); + System.Assert.areEqual(httpResponseHeadersAttribute.value.stringValue, publishedLogEntryEvent.HttpResponseHeaders__c); + System.Assert.areEqual(httpResponseStatusAttribute.value.stringValue, publishedLogEntryEvent.HttpResponseStatus__c); + System.Assert.areEqual(httpResponseStatusCodeAttribute.value.intValue, publishedLogEntryEvent.HttpResponseStatusCode__c); + // Logged By Fields + System.Assert.areEqual(loggedByFederationIdentifierAttribute.value.stringValue, publishedLogEntryEvent.LoggedByFederationIdentifier__c); + System.Assert.areEqual(loggedByIdAttribute.value.stringValue, publishedLogEntryEvent.LoggedById__c); + System.Assert.areEqual(loggedByUsernameAttribute.value.stringValue, publishedLogEntryEvent.LoggedByUsername__c); + // Other Fields + System.Assert.areEqual(parentLogTransactionIdAttribute.value.stringValue, publishedLogEntryEvent.ParentLogTransactionId__c); + System.Assert.areEqual(originStackTraceAttribute.value.stringValue, publishedLogEntryEvent.StackTrace__c); + Datetime expectedTimestamp = Datetime.newInstance(Long.valueOf(otelLogEntry.timeUnixNano) / 1000000); + System.Assert.areEqual(expectedTimestamp, publishedLogEntryEvent.Timestamp__c); + System.Assert.areEqual(otelLogEntry.name, publishedLogEntryEvent.EntryScenario__c); + String hyphenatedUuid = + otelLogEntry.traceId.substring(0, 8) + + '-' + + otelLogEntry.traceId.substring(8, 12) + + '-' + + otelLogEntry.traceId.substring(12, 16) + + '-' + + otelLogEntry.traceId.substring(16, 20) + + '-' + + otelLogEntry.traceId.substring(20, 32); + String expectedTransactionId = System.UUID.fromString(hyphenatedUuid).toString(); + System.Assert.areEqual(expectedTransactionId, publishedLogEntryEvent.TransactionId__c); + System.Assert.areEqual(1, publishedLogEntryEvent.TransactionEntryNumber__c); + } +} diff --git a/nebula-logger/core/tests/log-management/classes/LoggerRestResource_Tests.cls-meta.xml b/nebula-logger/core/tests/log-management/classes/LoggerRestResource_Tests.cls-meta.xml new file mode 100644 index 000000000..c01f6433a --- /dev/null +++ b/nebula-logger/core/tests/log-management/classes/LoggerRestResource_Tests.cls-meta.xml @@ -0,0 +1,5 @@ + + + 61.0 + Active + diff --git a/nebula-logger/core/tests/logger-engine/classes/ComponentLogger_Tests.cls b/nebula-logger/core/tests/logger-engine/classes/ComponentLogger_Tests.cls index adc36a1ac..6e5b6295d 100644 --- a/nebula-logger/core/tests/logger-engine/classes/ComponentLogger_Tests.cls +++ b/nebula-logger/core/tests/logger-engine/classes/ComponentLogger_Tests.cls @@ -69,7 +69,7 @@ private class ComponentLogger_Tests { System.Assert.areEqual('Component', publishedLogEntryEvent.OriginType__c); System.Assert.isNull( publishedLogEntryEvent.OriginSourceMetadataType__c, - 'Non-null value populated for OriginSourceMetadata__c: ' + System.JSON.serializePretty(publishedLogEntryEvent) + 'Non-null value populated for OriginSourceMetadataType__c: ' + System.JSON.serializePretty(publishedLogEntryEvent) ); System.Assert.isNull(publishedLogEntryEvent.StackTrace__c); System.Assert.areEqual(componentLogEntry.loggingLevel, publishedLogEntryEvent.LoggingLevel__c); diff --git a/package.json b/package.json index 4aaf72cdf..03763b198 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nebula-logger", - "version": "4.14.10", + "version": "4.14.11", "description": "The most robust logger for Salesforce. Works with Apex, Lightning Components, Flow, Process Builder & Integrations. Designed for Salesforce admins, developers & architects.", "author": "Jonathan Gillespie", "license": "MIT", @@ -58,7 +58,7 @@ "prettier:verify": "prettier --list-different \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"", "scan:fix:lwc": "eslint --config ./config/linters/.eslintrc.json **/lwc/** --fix", "scan": "npm run scan:apex && npm run scan:lwc", - "scan:apex": "sf scanner:run --pmdconfig ./config/linters/pmd-ruleset.xml --target ./nebula-logger/ --engine pmd --severity-threshold 3", + "scan:apex": "sf scanner run --pmdconfig ./config/linters/pmd-ruleset.xml --target ./nebula-logger/ --engine pmd --severity-threshold 3", "scan:lwc": "eslint --config ./config/linters/.eslintrc.json **/lwc/**", "sf:plugins:link:bummer": "npx sf plugins link ./node_modules/@jongpie/sfdx-bummer-plugin", "sf:plugins:link:prettier": "npx sf plugins link ./node_modules/@jayree/sfdx-plugin-prettier", diff --git a/sfdx-project.json b/sfdx-project.json index 0169bbefc..8b452f4b6 100644 --- a/sfdx-project.json +++ b/sfdx-project.json @@ -9,9 +9,9 @@ "path": "./nebula-logger/core", "definitionFile": "./config/scratch-orgs/base-scratch-def.json", "scopeProfiles": true, - "versionNumber": "4.14.10.NEXT", - "versionName": "New CallableLogger Apex class", - "versionDescription": "Added a new CallableLogger class that provides support for both OmniStudio logging, as well as the ability to dynamically call Nebula Logger in Apex when it's available", + "versionNumber": "4.14.11.NEXT", + "versionName": "OpenTelemetry (OTel) REST Resource", + "versionDescription": "Added a new LoggerRestResource class that provides an OTel-compatible endpoint for external integrations to store logging data in Salesforce", "releaseNotesUrl": "https://github.com/jongpie/NebulaLogger/releases", "unpackagedMetadata": { "path": "./nebula-logger/extra-tests" @@ -195,6 +195,7 @@ "Nebula Logger - Core@4.14.8-store-httprequest-header-keys-&-values": "04t5Y0000015oS1QAI", "Nebula Logger - Core@4.14.9-bugfix:-apex-code-snippets-auto-truncated": "04t5Y0000015oSQQAY", "Nebula Logger - Core@4.14.10-new-callablelogger-apex-class": "04t5Y0000015oTdQAI", + "Nebula Logger - Core@4.14.11-opentelemetry-(otel)-rest-resource": "04t5Y0000015oUHQAY", "Nebula Logger - Core Plugin - Async Failure Additions": "0Ho5Y000000blO4SAI", "Nebula Logger - Core Plugin - Async Failure Additions@1.0.0": "04t5Y0000015lhiQAA", "Nebula Logger - Core Plugin - Async Failure Additions@1.0.1": "04t5Y0000015lhsQAA",