From 0f93494270c89afe5f6cabbc72e7618caecd9b63 Mon Sep 17 00:00:00 2001 From: howardyoo Date: Thu, 13 Nov 2025 18:30:47 -0600 Subject: [PATCH 01/10] Initial version --- .../plugins/opentelemetry/INSTALLATION.md | 135 +++++ nebula-logger/plugins/opentelemetry/README.md | 342 +++++++++++++ .../opentelemetry/plugin/core/main/README.md | 7 + .../OpenTelemetryExportDate__c.field-meta.xml | 15 + .../SendToOpenTelemetry__c.field-meta.xml | 15 + ...sExportedToOpenTelemetry.listView-meta.xml | 23 + ...eExportedToOpenTelemetry.listView-meta.xml | 27 + .../classes/OpenTelemetryLoggerPlugin.cls | 460 ++++++++++++++++++ .../OpenTelemetryLoggerPlugin.cls-meta.xml | 6 + .../OpenTelemetryLoggerPlugin_Tests.cls | 338 +++++++++++++ ...enTelemetryLoggerPlugin_Tests.cls-meta.xml | 6 + ...ameter.OpenTelemetryAuthHeader.md-meta.xml | 14 + ...arameter.OpenTelemetryEndpoint.md-meta.xml | 14 + ...emetryNotificationLoggingLevel.md-meta.xml | 14 + ...meter.OpenTelemetryServiceName.md-meta.xml | 14 + ...er.OpenTelemetryServiceVersion.md-meta.xml | 14 + .../LoggerPlugin.OpenTelemetry.md-meta.xml | 64 +++ ...elemetryPluginAdmin.permissionset-meta.xml | 21 + 18 files changed, 1529 insertions(+) create mode 100644 nebula-logger/plugins/opentelemetry/INSTALLATION.md create mode 100644 nebula-logger/plugins/opentelemetry/README.md create mode 100644 nebula-logger/plugins/opentelemetry/plugin/core/main/README.md create mode 100644 nebula-logger/plugins/opentelemetry/plugin/core/main/log-management/objects/Log__c/fields/OpenTelemetryExportDate__c.field-meta.xml create mode 100644 nebula-logger/plugins/opentelemetry/plugin/core/main/log-management/objects/Log__c/fields/SendToOpenTelemetry__c.field-meta.xml create mode 100644 nebula-logger/plugins/opentelemetry/plugin/core/main/log-management/objects/Log__c/listViews/AllLogsExportedToOpenTelemetry.listView-meta.xml create mode 100644 nebula-logger/plugins/opentelemetry/plugin/core/main/log-management/objects/Log__c/listViews/AllLogsToBeExportedToOpenTelemetry.listView-meta.xml create mode 100644 nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls create mode 100644 nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls-meta.xml create mode 100644 nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls create mode 100644 nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls-meta.xml create mode 100644 nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryAuthHeader.md-meta.xml create mode 100644 nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryEndpoint.md-meta.xml create mode 100644 nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryNotificationLoggingLevel.md-meta.xml create mode 100644 nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryServiceName.md-meta.xml create mode 100644 nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryServiceVersion.md-meta.xml create mode 100644 nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerPlugin.OpenTelemetry.md-meta.xml create mode 100644 nebula-logger/plugins/opentelemetry/plugin/opentelemetry/permissionsets/LoggerOpenTelemetryPluginAdmin.permissionset-meta.xml diff --git a/nebula-logger/plugins/opentelemetry/INSTALLATION.md b/nebula-logger/plugins/opentelemetry/INSTALLATION.md new file mode 100644 index 000000000..7dbf624a6 --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/INSTALLATION.md @@ -0,0 +1,135 @@ +# OpenTelemetry Plugin - Quick Installation Guide + +## Prerequisites + +- Nebula Logger v4.7.1 or newer must be installed in your Salesforce org +- An OpenTelemetry endpoint that accepts OTLP/HTTP JSON format + +## Installation Steps + +### 1. Deploy the Plugin + +Deploy all files from the `nebula-logger/plugins/opentelemetry/plugin` directory to your Salesforce org: + +```bash +# Using Salesforce CLI +sf project deploy start --source-dir nebula-logger/plugins/opentelemetry/plugin +``` + +Or deploy the metadata using your preferred deployment method (VS Code, Workbench, Change Sets, etc.) + +### 2. Configure Remote Site Settings (if not using Named Credentials) + +1. Go to **Setup** → **Remote Site Settings** +2. Click **New Remote Site** +3. Enter: + - **Remote Site Name**: `OpenTelemetry` + - **Remote Site URL**: Your OTLP endpoint base URL (e.g., `https://otel-collector.example.com`) + - Check **Active** +4. Click **Save** + +### 3. Configure Logger Parameters + +1. Go to **Setup** → **Custom Metadata Types** → **Logger Parameter** → **Manage Records** + +2. Click on **OpenTelemetry Endpoint**: + - Set **Value**: Your OTLP endpoint URL (e.g., `https://otel-collector.example.com/v1/logs`) + - Or use Named Credential: `callout:YourNamedCredential` + - Click **Save** + +3. Click on **OpenTelemetry Notification Logging Level**: + - Set **Value**: The minimum logging level to export (e.g., `WARN`, `ERROR`, `INFO`) + - Click **Save** + +4. (Optional) Configure **OpenTelemetry Service Name**: + - Set **Value**: A descriptive name for your Salesforce org (e.g., `Salesforce-Production`) + - Default: `Salesforce` + +5. (Optional) Configure **OpenTelemetry Service Version**: + - Set **Value**: Version identifier (e.g., `1.0.0`, `2023-Q4`) + - Default: `1.0.0` + +6. (Optional) Configure **OpenTelemetry Auth Header** (if not using Named Credentials): + - Set **Value**: Authentication header in format `HeaderName: HeaderValue` + - Example: `Authorization: Bearer your-api-token` + - Or: `x-api-key: your-api-key` + - Leave blank if using Named Credentials + +### 4. Enable the Plugin + +1. Go to **Setup** → **Custom Metadata Types** → **Logger Plugin** → **Manage Records** +2. Click on **OpenTelemetry integration** +3. Ensure **Is Enabled** is checked +4. Click **Save** + +### 5. Assign Permissions (Optional) + +If you want users to be able to view/edit the OpenTelemetry fields on Log records: + +1. Go to **Setup** → **Permission Sets** +2. Find **Nebula Logger: OpenTelemetry Plugin Admin** +3. Assign it to the appropriate users or integrate it into your permission set groups + +### 6. Test the Integration + +Create a test log to verify the integration: + +```apex +// Execute in Anonymous Apex +Logger.error('Testing OpenTelemetry integration'); +Logger.saveLog(); +``` + +Then verify: +1. Check the **Log** record created +2. The **Send to OpenTelemetry** field should be checked (if the logging level meets the threshold) +3. After a few seconds, the **OpenTelemetry Export Date** field should be populated +4. Check your OpenTelemetry backend to see the log entry + +--- + +## Troubleshooting + +### Logs not appearing in OpenTelemetry + +1. **Check Log Records**: + - Go to the **Logs** tab + - Open the list view **All Logs to be Exported to OpenTelemetry** + - Verify logs appear here + - Check if **OpenTelemetry Export Date** is populated + +2. **Check Debug Logs**: + - Go to **Setup** → **Debug Logs** + - Create a debug log for the user + - Create a new log entry + - Review the debug log for any errors related to OpenTelemetry + +3. **Verify Async Jobs**: + - Go to **Setup** → **Apex Jobs** + - Look for `OpenTelemetryLoggerPlugin` jobs + - Check if any have failed + +4. **Check Configuration**: + - Verify the endpoint URL is correct and accessible + - Verify authentication is properly configured + - Ensure Remote Site Settings or Named Credentials are set up + +### Common Issues + +- **401 Unauthorized**: Check your authentication configuration (Auth Header or Named Credentials) +- **404 Not Found**: Verify the endpoint URL includes the full path (e.g., `/v1/logs`) +- **No callout**: Verify Remote Site Settings are configured for your endpoint domain + +--- + +## Next Steps + +1. Review the main [README.md](README.md) for detailed configuration options +2. Explore the data mapping and query examples +3. Configure custom attributes using Logger tags +4. Set up dashboards in your observability platform + +## Support + +For issues, questions, or contributions, please visit the [Nebula Logger GitHub repository](https://github.com/jongpie/NebulaLogger). + diff --git a/nebula-logger/plugins/opentelemetry/README.md b/nebula-logger/plugins/opentelemetry/README.md new file mode 100644 index 000000000..2cc4b145b --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/README.md @@ -0,0 +1,342 @@ +# OpenTelemetry plugin for Nebula Logger + +> :information_source: This plugin requires `v4.7.1` or newer of Nebula Logger's unlocked package + +Adds an OpenTelemetry integration for the unlocked package edition of Nebula Logger. Any logs with log entries that meet a certain (configurable) logging level will automatically be exported to your OpenTelemetry endpoint via an asynchronous `Queueable` job using the OTLP (OpenTelemetry Protocol) JSON format. + +--- + +## What's Included + +This plugin includes some add-on metadata for Nebula Logger to support the OpenTelemetry integration + +1. Apex class `OpenTelemetryLoggerPlugin` and corresponding tests in `OpenTelemetryLoggerPlugin_Tests` +2. Plugin configuration details stored in Logger's CMDT objects `LoggerPlugin__mdt` and `LoggerParameter__mdt` +3. Custom fields `Log__c.SendToOpenTelemetry__c` and `Log__c.OpenTelemetryExportDate__c` +4. Field-level security (FLS) via a new permission set `LoggerOpenTelemetryPluginAdmin` to provide access to the custom OpenTelemetry fields +5. Two custom list views for the `Log__c` object to see any `Log__c` records that have been, or should be, exported to OpenTelemetry + +--- + +## OpenTelemetry Overview + +OpenTelemetry (OTEL) is an open-source observability framework that provides vendor-neutral APIs and tools for collecting telemetry data (logs, metrics, and traces). This plugin sends Salesforce logs to any OpenTelemetry-compatible backend such as: + +- OpenTelemetry Collector +- Grafana Loki +- Elastic Stack +- Datadog +- New Relic +- Honeycomb +- AWS X-Ray +- Azure Monitor +- Google Cloud Operations +- And many more... + +### OTLP Format + +This plugin uses the OTLP (OpenTelemetry Protocol) JSON format for sending logs. Each Salesforce log is converted into OpenTelemetry LogRecords with: + +- **Timestamp**: Nanosecond precision timestamp +- **Severity**: Mapped from Salesforce logging levels to OTEL severity numbers +- **Body**: The main log message +- **Attributes**: Key-value pairs including: + - Transaction ID + - User information + - Organization ID + - Exception details + - Stack traces + - Salesforce limits information +- **Trace Context**: Using Transaction ID as trace ID for distributed tracing correlation + +--- + +## Installation Steps + +In order to use the OpenTelemetry plugin, there are some configuration changes needed in both your OpenTelemetry backend and Salesforce. + +### OpenTelemetry Backend Setup + +You'll need an OpenTelemetry endpoint that accepts OTLP/HTTP JSON format. Options include: + +1. **OpenTelemetry Collector** - Deploy your own collector that can forward logs to multiple backends +2. **Managed Services** - Use a managed observability platform that supports OTLP (e.g., Grafana Cloud, Datadog, New Relic) +3. **Self-hosted backends** - Configure backends like Loki, Elasticsearch, etc. to accept OTLP + +The endpoint URL should be the OTLP HTTP endpoint, typically ending in `/v1/logs` (e.g., `https://otel-collector.example.com/v1/logs`) + +### Salesforce Setup + +1. Ensure that you have the unlocked package version of Nebula Logger installed in your org +2. Deploy the OpenTelemetry plugin metadata to your org +3. Go to Setup --> Custom Metadata Types --> Logger Parameters. Configure the following parameters: + + - **Parameter 'OpenTelemetry Endpoint'** - You can configure this endpoint in 1 of 2 ways: + - Easier but less secure: Paste the OTLP endpoint URL into the `Value__c` field and save the Parameter record + - More secure: Create a new Named Credential ([see section below for step-by-step instructions](#setting-up-named-credentials)), using the endpoint URL. Within the Parameter 'OpenTelemetry Endpoint', enter `callout:` into the `Value__c` field and save the Parameter record + + - **Parameter 'OpenTelemetry Notification Logging Level'** - Set the desired logging level value that should trigger logs to be exported to OpenTelemetry. It controls which logging level (ERROR, WARN, INFO, DEBUG, FINE, FINER, or FINEST) will trigger the exports. + + - **Parameter 'OpenTelemetry Service Name'** (Optional) - Set the service name to identify your Salesforce org in the observability platform. Defaults to 'Salesforce' if not specified. + + - **Parameter 'OpenTelemetry Service Version'** (Optional) - Set the service version. Defaults to '1.0.0' if not specified. + + - **Parameter 'OpenTelemetry Auth Header'** (Optional) - If your endpoint requires authentication and you're not using Named Credentials, you can specify the authentication header here in the format `HeaderName: HeaderValue` (e.g., `Authorization: Bearer your-token` or `x-api-key: your-key`). Leave blank if using Named Credentials or if no authentication is required. + +4. If not using Named Credentials, add a Remote Site Setting for your OpenTelemetry endpoint: + - Go to Setup --> Remote Site Settings --> New Remote Site + - Enter a name (e.g., 'OpenTelemetry') + - Enter your OTLP endpoint URL (e.g., `https://otel-collector.example.com`) + - Check 'Active' and save + +The OpenTelemetry integration should now be setup & working - any new logs that meet the specified notification logging level will be exported to your OpenTelemetry backend. + +#### Setting up Named Credentials + +_Note: these instructions are for setting up the improved Named Credentials, as legacy credentials are deprecated as of Winter '23. For more info, see [Salesforce's documentation](https://help.salesforce.com/s/articleView?id=sf.named_credentials_about.htm&type=5)._ + +1. **Create a new External Credential.** This will define how Salesforce should authenticate with the OTLP endpoint. + + - Go to the Named Credentials page in setup, click `New` under the External Credentials tab. + - Enter a name (for example, `OpenTelemetry Endpoint`) + - Select the appropriate Authentication Protocol: + - For API key authentication: Select `Password Authentication` and configure accordingly + - For bearer token: Select `JWT` or `Password Authentication` + - For no authentication: Select `No Authentication` + +2. **Create a Principle for the External Credential.** This will define the credentials that should be used when calling out to the OTLP endpoint. + + - In the Principals section of the External Credential you just created, click `New`. + - Enter a parameter name (for example: `Default` or `Standard`). + - If using authentication, enter the required credentials (API key, username/password, etc.) + +3. **Create a new Named Credential.** This is where the OTLP endpoint URL will be stored. + + - Go back to the main Named Credentials page and click `New` in the Named Credentials tab. + - Enter a name for the Named Credential (for example: `OpenTelemetry_Collector`). + - Paste the OTLP endpoint URL into the URL field (e.g., `https://otel-collector.example.com/v1/logs`). + - In the External Credential dropdown, select the one you created in step 1. + +4. **Grant the Platform Integration User access to the External Credential.** This will allow the Platform Integration user (the running user for queueable jobs) to make callouts to the OTLP endpoint. + + - Create a new permission set or open an existing one + - Go to the External Credential Principal Access section of the permission set and grant access to the External Credential you created in step 1. + - Assign the permission set to the user that runs async jobs (typically the Automated Process user or the user whose context the queueable runs in). + +--- + +## Data Mapping + +### Logging Level to Severity Mapping + +Salesforce logging levels are mapped to OpenTelemetry severity numbers as follows: + +| Salesforce Level | OTEL Severity Number | OTEL Severity Name | +|-----------------|---------------------|-------------------| +| FINEST | 1 | TRACE | +| FINER | 5 | DEBUG | +| FINE | 9 | DEBUG2 | +| DEBUG | 9 | DEBUG2 | +| INFO | 13 | INFO2 | +| WARN | 17 | WARN2 | +| ERROR | 21 | ERROR2 | + +### LogRecord Attributes + +The plugin includes the following attributes in each LogRecord: + +#### Standard Attributes + +- `transaction.id` - Salesforce transaction ID +- `user.name` - Username of the user who created the log +- `organization.id` - Salesforce organization ID +- `api.version` - Salesforce API version used + +#### Exception Attributes (when applicable) + +- `exception.type` - Exception type (e.g., NullPointerException) +- `exception.message` - Exception message +- `exception.stacktrace` - Exception stack trace + +#### Code Attributes + +- `code.stacktrace` - Apex stack trace + +#### Record Attributes (when applicable) + +- `record.id` - Salesforce record ID associated with the log entry + +#### Limits Attributes (when applicable) + +- `limits.aggregate_queries` - Number of aggregate queries used +- `limits.cpu_time` - CPU time used in milliseconds +- `limits.heap_size` - Heap size used in bytes + +### Trace Context + +The plugin includes trace context to enable correlation with distributed traces: + +- **Trace ID**: Generated from the Salesforce Transaction ID (32-character hex string) +- **Span ID**: Generated from the Log Entry ID (16-character hex string) + +This allows you to correlate logs with traces in your observability platform, providing a complete view of transactions across systems. + +--- + +## Usage Examples + +### Basic Usage + +Once configured, the plugin works automatically. Logs that meet the configured logging level threshold will be automatically exported: + +```apex +// This log entry will be exported if the threshold is WARN or lower +Logger.error('Payment processing failed for order ' + orderId); +Logger.saveLog(); +``` + +### With Exception Details + +Exception information is automatically captured and mapped to OTEL attributes: + +```apex +try { + processOrder(orderId); +} catch (Exception e) { + Logger.error('Order processing failed', e); + Logger.saveLog(); +} +``` + +### With Custom Attributes + +Use tags to add custom attributes: + +```apex +Logger.error('API call failed') + .addTag('api.endpoint', 'https://api.example.com/v1/orders') + .addTag('http.status_code', '500'); +Logger.saveLog(); +``` + +--- + +## Querying Logs in OpenTelemetry + +Once your logs are in an OpenTelemetry-compatible backend, you can query them using the platform's query language. Here are some example queries: + +### Grafana Loki (LogQL) + +```logql +# Find all ERROR logs from the last hour +{service_name="Salesforce"} |= "ERROR" + +# Find logs for a specific transaction +{service_name="Salesforce"} | json | transaction_id="1234-5678-90ab" + +# Find logs with exceptions +{service_name="Salesforce"} | json | exception_type != "" +``` + +### Elasticsearch + +```json +{ + "query": { + "bool": { + "must": [ + { "match": { "resource.attributes.service.name": "Salesforce" }}, + { "range": { "severityNumber": { "gte": 17 }}} + ] + } + } +} +``` + +--- + +## Troubleshooting + +### Logs are not being exported + +1. Check that the plugin is enabled: + - Go to Setup --> Custom Metadata Types --> Logger Plugins + - Verify that the 'OpenTelemetry integration' record has `IsEnabled__c` = true + +2. Check the logging level threshold: + - Go to Setup --> Custom Metadata Types --> Logger Parameters + - Verify that 'OpenTelemetry Notification Logging Level' is set to the desired level + - Ensure your logs meet or exceed this threshold + +3. Check the endpoint configuration: + - Verify the 'OpenTelemetry Endpoint' parameter is correctly configured + - If using a direct URL, ensure a Remote Site Setting is configured + - If using Named Credentials, verify the credential is properly configured and has the correct permissions + +4. Check for errors: + - Look for logs created by the plugin itself (they should contain "OpenTelemetry" in the message) + - Check the custom fields on your Log records: + - `SendToOpenTelemetry__c` should be true for logs that should be exported + - `OpenTelemetryExportDate__c` should be populated after successful export + +### HTTP Callout Errors + +If you're seeing HTTP errors: + +1. **401 Unauthorized**: Check your authentication configuration + - Verify the `OpenTelemetry Auth Header` parameter or Named Credential credentials + +2. **404 Not Found**: Verify the endpoint URL is correct + - Ensure it includes the full path (e.g., `/v1/logs`) + +3. **SSL/TLS Errors**: Ensure your endpoint uses a valid SSL certificate + - Self-signed certificates may require additional configuration in Salesforce + +### Governor Limits + +The plugin is designed to handle governor limits efficiently: + +- Callouts are made asynchronously via queueable jobs +- If the callout limit is reached, remaining logs are queued in a new job +- Each log is processed individually to prevent bulk failures + +--- + +## Performance Considerations + +- **Async Processing**: All OpenTelemetry exports are performed asynchronously to avoid impacting user transactions +- **Batch Processing**: Multiple log entries from the same transaction are sent together +- **Governor Limits**: The plugin respects Salesforce governor limits and chains queueable jobs when needed +- **Network Overhead**: Each export requires an HTTP callout; consider the logging level threshold to balance observability with network overhead + +--- + +## Security Best Practices + +1. **Use Named Credentials**: Instead of storing endpoint URLs and API keys directly in custom metadata, use Named Credentials for better security +2. **Restrict Field Access**: Use the provided permission set to control who can view/edit the OpenTelemetry fields +3. **Monitor Callouts**: Regularly review callout logs to detect any unauthorized access attempts +4. **Secure Your Endpoint**: Ensure your OTLP endpoint uses HTTPS and proper authentication +5. **Data Sensitivity**: Be mindful of sensitive data in logs; Nebula Logger's data masking features apply before export + +--- + +## Compatibility + +- **Nebula Logger**: Requires v4.7.1 or newer +- **Salesforce API**: Tested with API version 62.0 +- **OTLP Version**: Supports OTLP/HTTP JSON format 1.0.0 +- **OpenTelemetry Specification**: Complies with OpenTelemetry Logs Data Model specification + +--- + +## Contributing + +Found a bug or have a feature request? Please open an issue on the [Nebula Logger GitHub repository](https://github.com/jongpie/NebulaLogger/issues). + +--- + +## License + +This plugin is released under the MIT License as part of the Nebula Logger project. See the main project LICENSE file for details. + diff --git a/nebula-logger/plugins/opentelemetry/plugin/core/main/README.md b/nebula-logger/plugins/opentelemetry/plugin/core/main/README.md new file mode 100644 index 000000000..0b384c49a --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/plugin/core/main/README.md @@ -0,0 +1,7 @@ +This folder contains metadata that is being appended to metadata that is owned by the core Logger package: + +- New fields are added to the `Log__c` object +- New list views are added to the `Log__c` object + +The folder structure mimics the same structure used by the core package (e.g., both directories have the structure `core/main/log-management`). This makes it easier for orgs that prefer to deploy the unpackaged metadata (instead of using the unlocked package) to combine the folders together, if desired. + diff --git a/nebula-logger/plugins/opentelemetry/plugin/core/main/log-management/objects/Log__c/fields/OpenTelemetryExportDate__c.field-meta.xml b/nebula-logger/plugins/opentelemetry/plugin/core/main/log-management/objects/Log__c/fields/OpenTelemetryExportDate__c.field-meta.xml new file mode 100644 index 000000000..3bebb0662 --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/plugin/core/main/log-management/objects/Log__c/fields/OpenTelemetryExportDate__c.field-meta.xml @@ -0,0 +1,15 @@ + + + OpenTelemetryExportDate__c + Active + None + The date/time that the log was exported to OpenTelemetry + The date/time that the log was exported to OpenTelemetry + + false + Confidential + true + false + DateTime + + diff --git a/nebula-logger/plugins/opentelemetry/plugin/core/main/log-management/objects/Log__c/fields/SendToOpenTelemetry__c.field-meta.xml b/nebula-logger/plugins/opentelemetry/plugin/core/main/log-management/objects/Log__c/fields/SendToOpenTelemetry__c.field-meta.xml new file mode 100644 index 000000000..c3a6011c4 --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/plugin/core/main/log-management/objects/Log__c/fields/SendToOpenTelemetry__c.field-meta.xml @@ -0,0 +1,15 @@ + + + SendToOpenTelemetry__c + Active + None + false + Indicates whether this log should be sent to OpenTelemetry + Indicates whether this log should be sent to OpenTelemetry + + Confidential + true + false + Checkbox + + diff --git a/nebula-logger/plugins/opentelemetry/plugin/core/main/log-management/objects/Log__c/listViews/AllLogsExportedToOpenTelemetry.listView-meta.xml b/nebula-logger/plugins/opentelemetry/plugin/core/main/log-management/objects/Log__c/listViews/AllLogsExportedToOpenTelemetry.listView-meta.xml new file mode 100644 index 000000000..c28350fb6 --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/plugin/core/main/log-management/objects/Log__c/listViews/AllLogsExportedToOpenTelemetry.listView-meta.xml @@ -0,0 +1,23 @@ + + + AllLogsExportedToOpenTelemetry + NAME + LoggedByUsernameLink__c + StartTime__c + OWNER.ALIAS + Priority__c + Status__c + TransactionId__c + TotalLimitsCpuTimeUsed__c + TotalLogEntries__c + TotalERRORLogEntries__c + TotalWARNLogEntries__c + OpenTelemetryExportDate__c + Everything + + OpenTelemetryExportDate__c + notEqual + + + + diff --git a/nebula-logger/plugins/opentelemetry/plugin/core/main/log-management/objects/Log__c/listViews/AllLogsToBeExportedToOpenTelemetry.listView-meta.xml b/nebula-logger/plugins/opentelemetry/plugin/core/main/log-management/objects/Log__c/listViews/AllLogsToBeExportedToOpenTelemetry.listView-meta.xml new file mode 100644 index 000000000..eb442e079 --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/plugin/core/main/log-management/objects/Log__c/listViews/AllLogsToBeExportedToOpenTelemetry.listView-meta.xml @@ -0,0 +1,27 @@ + + + AllLogsToBeExportedToOpenTelemetry + NAME + LoggedByUsernameLink__c + StartTime__c + OWNER.ALIAS + Priority__c + Status__c + TransactionId__c + TotalLimitsCpuTimeUsed__c + TotalLogEntries__c + TotalERRORLogEntries__c + TotalWARNLogEntries__c + Everything + + OpenTelemetryExportDate__c + equals + + + SendToOpenTelemetry__c + equals + 1 + + + + diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls new file mode 100644 index 000000000..bdd466bb9 --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls @@ -0,0 +1,460 @@ +//------------------------------------------------------------------------------------------------// +// 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 Plugins + * @description Optional plugin that integrates with OpenTelemetry to send log records using the OTLP format + */ +@SuppressWarnings('PMD.ApexCRUDViolation, PMD.CyclomaticComplexity, PMD.ExcessivePublicCount') +public without sharing class OpenTelemetryLoggerPlugin implements LoggerPlugin.Triggerable, System.Queueable, Database.AllowsCallouts { + @TestVisible + private static final String ENDPOINT = LoggerParameter.getString('OpenTelemetryEndpoint', null); + @TestVisible + private static final String SERVICE_NAME = LoggerParameter.getString('OpenTelemetryServiceName', 'Salesforce'); + @TestVisible + private static final String SERVICE_VERSION = LoggerParameter.getString('OpenTelemetryServiceVersion', '1.0.0'); + @TestVisible + private static final System.LoggingLevel NOTIFICATION_LOGGING_LEVEL = Logger.getLoggingLevel( + LoggerParameter.getString('OpenTelemetryNotificationLoggingLevel', System.LoggingLevel.WARN.name()) + ); + @TestVisible + private static final String OTLP_VERSION = '1.0.0'; + @TestVisible + private static final String AUTH_HEADER = LoggerParameter.getString('OpenTelemetryAuthHeader', null); + + private List logs; + + /** + * @description Default constructor + */ + @SuppressWarnings('PMD.EmptyStatementBlock') + public OpenTelemetryLoggerPlugin() { + } + + private OpenTelemetryLoggerPlugin(List unsentLogs) { + this(); + this.logs = unsentLogs; + } + + /** + * @description Handles the integration with OpenTelemetry. This method is automatically called by Nebula Logger's plugin framework. + * @param configuration The instance of `LoggerPlugin__mdt` configured for this specific plugin + * @param context The instance of `LoggerTriggerableContext`, provided by the logging system + */ + public void execute(LoggerPlugin__mdt configuration, LoggerTriggerableContext context) { + if (context.sobjectType != Schema.Log__c.SObjectType || String.isBlank(ENDPOINT)) { + return; + } + + this.logs = (List) context.triggerNew; + switch on context.triggerOperationType { + when BEFORE_INSERT, BEFORE_UPDATE { + this.flagLogsForOpenTelemetryExport(); + } + when AFTER_INSERT, AFTER_UPDATE { + this.sendAsyncOpenTelemetryLogs(); + } + } + } + + /** + * @description Handles the queueable execute logic. Required by the System.Queueable interface. + * @param queueableContext Context of the current queueable instance. + */ + public void execute(System.QueueableContext queueableContext) { + // Since this runs in an async context, requery the logs just in case any field values have changed + this.requeryLogs(); + + if (this.logs.isEmpty()) { + return; + } + + List sentLogs = new List(); + List unsentLogs = new List(); + + for (Log__c log : this.logs) { + if (System.Limits.getCallouts() == System.Limits.getLimitCallouts()) { + // If there are too many logs to send in the same transaction + // add them to the unsentLogs list, which will be queued as a separate job + unsentLogs.add(log); + continue; + } + + System.HttpRequest request = this.createOpenTelemetryHttpRequest(log); + if (LoggerParameter.ENABLE_SYSTEM_MESSAGES) { + Logger.finest('Sending log entries to OpenTelemetry endpoint').setHttpRequestDetails(request); + } + + System.HttpResponse response = new System.Http().send(request); + if (LoggerParameter.ENABLE_SYSTEM_MESSAGES) { + Logger.finest('Sent log entries to OpenTelemetry endpoint').setHttpResponseDetails(response); + } + + log.OpenTelemetryExportDate__c = System.now(); + sentLogs.add(log); + } + Logger.saveLog(); + update sentLogs; + + // If any logs couldn't be sent due to governor limits, start a new instance of the job + if (unsentLogs.size() > 0) { + System.enqueueJob(new OpenTelemetryLoggerPlugin(unsentLogs)); + } + } + + private void flagLogsForOpenTelemetryExport() { + if (NOTIFICATION_LOGGING_LEVEL == null) { + return; + } + + for (Log__c log : this.logs) { + if (log.MaxLogEntryLoggingLevelOrdinal__c >= NOTIFICATION_LOGGING_LEVEL.ordinal()) { + log.SendToOpenTelemetry__c = true; + } + } + } + + private void sendAsyncOpenTelemetryLogs() { + List logsToSend = new List(); + for (Log__c log : this.logs) { + if (log.SendToOpenTelemetry__c) { + logsToSend.add(log); + } + } + // Since plugins are called from trigger handlers, and triggers can't make callouts + // run this class as a queueable (async) job + if (logsToSend.isEmpty() == false) { + System.enqueueJob(new OpenTelemetryLoggerPlugin(logsToSend)); + } + } + + @SuppressWarnings('PMD.UnusedLocalVariable') + private void requeryLogs() { + Integer loggingLevelOrdinal = NOTIFICATION_LOGGING_LEVEL.ordinal(); + String logEntryChildQuery = + '\n(' + + '\nSELECT Id, LoggingLevel__c, LoggingLevelOrdinal__c, Message__c, ExceptionType__c, ExceptionMessage__c,' + + '\n ExceptionStackTrace__c, StackTrace__c, Timestamp__c, RecordId__c, RecordJson__c,' + + '\n HttpRequestBodyMasked__c, HttpResponseBody__c, LimitsAggregateQueriesUsed__c,' + + '\n LimitsCpuTimeUsed__c, LimitsHeapSizeUsed__c' + + '\nFROM LogEntries__r' + + '\nWHERE LoggingLevelOrdinal__c >= :loggingLevelOrdinal' + + '\nORDER BY Timestamp__c ASC' + + '\n)'; + List fieldNames = new List{ + Schema.Log__c.Name.toString(), + Schema.Log__c.ApiVersion__c.toString(), + Schema.Log__c.OrganizationId__c.toString(), + Schema.Log__c.TransactionId__c.toString(), + Schema.Log__c.StartTime__c.toString(), + logEntryChildQuery, + 'LoggedBy__r.Username', + 'TYPEOF Owner WHEN User THEN Username ELSE Name END' + }; + // Deduplicate the list of field names + fieldNames = new List(new Set(fieldNames)); + String query = + 'SELECT ' + + String.join(fieldNames, ', ') + + ' FROM Log__c' + + '\nWHERE Id IN :logs' + + '\nAND MaxLogEntryLoggingLevelOrdinal__c >= :loggingLevelOrdinal' + + '\nAND SendToOpenTelemetry__c = TRUE' + + '\nAND OpenTelemetryExportDate__c = NULL'; + this.logs = (List) System.Database.query(query); + } + + private System.HttpRequest createOpenTelemetryHttpRequest(Log__c log) { + System.HttpRequest request = new System.HttpRequest(); + request.setEndpoint(ENDPOINT); + request.setMethod('POST'); + request.setHeader('Content-Type', 'application/json'); + + // Add authentication header if configured + if (String.isNotBlank(AUTH_HEADER)) { + // Expected format: "Authorization: Bearer " or "x-api-key: " + List headerParts = AUTH_HEADER.split(':', 2); + if (headerParts.size() == 2) { + request.setHeader(headerParts[0].trim(), headerParts[1].trim()); + } + } + + // Build the OTLP JSON payload + String payload = this.buildOtlpPayload(log); + request.setBody(payload); + + if (LoggerParameter.ENABLE_SYSTEM_MESSAGES) { + Logger.finest('Created OpenTelemetry HTTP Request').setHttpRequestDetails(request); + } + return request; + } + + @SuppressWarnings('PMD.NcssMethodCount') + private String buildOtlpPayload(Log__c log) { + OtlpPayload payload = new OtlpPayload(); + payload.resourceLogs = new List(); + + ResourceLogs resourceLog = new ResourceLogs(); + resourceLog.resource = this.buildResource(); + resourceLog.scopeLogs = new List(); + + ScopeLogs scopeLog = new ScopeLogs(); + scopeLog.scope = this.buildScope(); + scopeLog.logRecords = new List(); + + // Convert each LogEntry to a LogRecord + for (LogEntry__c logEntry : log.LogEntries__r) { + scopeLog.logRecords.add(this.convertLogEntryToLogRecord(log, logEntry)); + } + + resourceLog.scopeLogs.add(scopeLog); + payload.resourceLogs.add(resourceLog); + + return System.JSON.serialize(payload); + } + + private Resource buildResource() { + Resource resource = new Resource(); + resource.attributes = new List(); + + // Add service.name + Attribute serviceName = new Attribute(); + serviceName.key = 'service.name'; + serviceName.value = new AttributeValue(); + serviceName.value.stringValue = SERVICE_NAME; + resource.attributes.add(serviceName); + + // Add service.version + Attribute serviceVersion = new Attribute(); + serviceVersion.key = 'service.version'; + serviceVersion.value = new AttributeValue(); + serviceVersion.value.stringValue = SERVICE_VERSION; + resource.attributes.add(serviceVersion); + + // Add organization.id + Attribute orgId = new Attribute(); + orgId.key = 'organization.id'; + orgId.value = new AttributeValue(); + orgId.value.stringValue = System.UserInfo.getOrganizationId(); + resource.attributes.add(orgId); + + return resource; + } + + private Scope buildScope() { + Scope scope = new Scope(); + scope.name = 'NebulaLogger'; + scope.version = OTLP_VERSION; + scope.attributes = new List(); + return scope; + } + + @SuppressWarnings('PMD.NcssMethodCount, PMD.CognitiveComplexity') + private LogRecord convertLogEntryToLogRecord(Log__c log, LogEntry__c logEntry) { + LogRecord record = new LogRecord(); + + // Set timestamp in nanoseconds (Salesforce DateTime is in milliseconds) + Long timestampMillis = logEntry.Timestamp__c.getTime(); + record.timeUnixNano = String.valueOf(timestampMillis * 1000000); + + // Set observed timestamp (when the log was recorded) + record.observedTimeUnixNano = record.timeUnixNano; + + // Set severity + record.severityNumber = this.mapLoggingLevelToSeverityNumber(logEntry.LoggingLevel__c); + record.severityText = logEntry.LoggingLevel__c; + + // Set body (main log message) + record.body = new AttributeValue(); + record.body.stringValue = logEntry.Message__c; + + // Set attributes + record.attributes = new List(); + + // Add transaction ID + this.addStringAttribute(record.attributes, 'transaction.id', log.TransactionId__c); + + // Add logged by user + this.addStringAttribute(record.attributes, 'user.name', log.LoggedBy__r.Username); + + // Add organization ID + this.addStringAttribute(record.attributes, 'organization.id', log.OrganizationId__c); + + // Add API version + if (log.ApiVersion__c != null) { + this.addStringAttribute(record.attributes, 'api.version', String.valueOf(log.ApiVersion__c)); + } + + // Add exception information if present + if (String.isNotBlank(logEntry.ExceptionType__c)) { + this.addStringAttribute(record.attributes, 'exception.type', logEntry.ExceptionType__c); + } + if (String.isNotBlank(logEntry.ExceptionMessage__c)) { + this.addStringAttribute(record.attributes, 'exception.message', logEntry.ExceptionMessage__c); + } + if (String.isNotBlank(logEntry.ExceptionStackTrace__c)) { + this.addStringAttribute(record.attributes, 'exception.stacktrace', logEntry.ExceptionStackTrace__c); + } + + // Add stack trace + if (String.isNotBlank(logEntry.StackTrace__c)) { + this.addStringAttribute(record.attributes, 'code.stacktrace', logEntry.StackTrace__c); + } + + // Add record information if present + if (String.isNotBlank(logEntry.RecordId__c)) { + this.addStringAttribute(record.attributes, 'record.id', logEntry.RecordId__c); + } + + // Add limits information + if (logEntry.LimitsAggregateQueriesUsed__c != null) { + this.addIntAttribute(record.attributes, 'limits.aggregate_queries', Integer.valueOf(logEntry.LimitsAggregateQueriesUsed__c)); + } + if (logEntry.LimitsCpuTimeUsed__c != null) { + this.addIntAttribute(record.attributes, 'limits.cpu_time', Integer.valueOf(logEntry.LimitsCpuTimeUsed__c)); + } + if (logEntry.LimitsHeapSizeUsed__c != null) { + this.addIntAttribute(record.attributes, 'limits.heap_size', Integer.valueOf(logEntry.LimitsHeapSizeUsed__c)); + } + + // Add trace context (using TransactionId as trace ID) + record.traceId = this.convertToHexTraceId(log.TransactionId__c); + record.spanId = this.convertToHexSpanId(logEntry.Id); + + return record; + } + + private void addStringAttribute(List attributes, String key, String value) { + if (String.isBlank(value)) { + return; + } + Attribute attr = new Attribute(); + attr.key = key; + attr.value = new AttributeValue(); + attr.value.stringValue = value; + attributes.add(attr); + } + + private void addIntAttribute(List attributes, String key, Integer value) { + if (value == null) { + return; + } + Attribute attr = new Attribute(); + attr.key = key; + attr.value = new AttributeValue(); + attr.value.intValue = String.valueOf(value); + attributes.add(attr); + } + + private Integer mapLoggingLevelToSeverityNumber(String loggingLevel) { + // Map Salesforce logging levels to OpenTelemetry severity numbers + // See: https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber + switch on loggingLevel { + when 'FINEST' { + return 1; // TRACE + } + when 'FINER' { + return 5; // DEBUG + } + when 'FINE' { + return 9; // DEBUG2 + } + when 'DEBUG' { + return 9; // DEBUG2 + } + when 'INFO' { + return 13; // INFO2 + } + when 'WARN' { + return 17; // WARN2 + } + when 'ERROR' { + return 21; // ERROR2 + } + when else { + return 13; // INFO2 as default + } + } + } + + private String convertToHexTraceId(String transactionId) { + // Convert transaction ID to a 32-character hex string for trace ID + if (String.isBlank(transactionId)) { + return null; + } + String hexString = EncodingUtil.convertToHex(Blob.valueOf(transactionId)).toLowerCase(); + // Ensure it's 32 characters (pad or truncate) + return hexString.length() >= 32 ? hexString.substring(0, 32) : hexString.rightPad(32, '0'); + } + + private String convertToHexSpanId(String logEntryId) { + // Convert log entry ID to a 16-character hex string for span ID + if (String.isBlank(logEntryId)) { + return null; + } + String hexString = EncodingUtil.convertToHex(Blob.valueOf(logEntryId)).toLowerCase(); + // Ensure it's 16 characters (pad or truncate) + return hexString.length() >= 16 ? hexString.substring(0, 16) : hexString.rightPad(16, '0'); + } + + // Private DTO classes that match OpenTelemetry OTLP JSON format + @TestVisible + private class OtlpPayload { + public List resourceLogs; + } + + @TestVisible + private class ResourceLogs { + public Resource resource; + public List scopeLogs; + } + + @TestVisible + private class Resource { + public List attributes; + } + + @TestVisible + private class ScopeLogs { + public Scope scope; + public List logRecords; + } + + @TestVisible + private class Scope { + public String name; + public String version; + public List; + } + + @SuppressWarnings('PMD.FieldNamingConventions, PMD.VariableNamingConventions') + @TestVisible + private class LogRecord { + public String timeUnixNano; + public String observedTimeUnixNano; + public Integer severityNumber; + public String severityText; + public AttributeValue body; + public List attributes; + public String traceId; + public String spanId; + } + + @TestVisible + private class Attribute { + public String key; + public AttributeValue value; + } + + @SuppressWarnings('PMD.FieldNamingConventions, PMD.VariableNamingConventions') + @TestVisible + private class AttributeValue { + public String stringValue; + public String intValue; + public String doubleValue; + public String boolValue; + } +} + diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls-meta.xml b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls-meta.xml new file mode 100644 index 000000000..a89d3abfc --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls-meta.xml @@ -0,0 +1,6 @@ + + + 62.0 + Active + + diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls new file mode 100644 index 000000000..0cb9ca776 --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls @@ -0,0 +1,338 @@ +//------------------------------------------------------------------------------------------------// +// 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') +@IsTest(IsParallel=true) +private class OpenTelemetryLoggerPlugin_Tests { + @IsTest + static void it_should_not_flag_log_to_send_opentelemetry_when_logging_level_is_not_met() { + System.LoggingLevel logEntryLoggingLevel = System.LoggingLevel.WARN; + System.LoggingLevel openTelemetryLoggingLevel = System.LoggingLevel.ERROR; + System.Assert.isTrue(logEntryLoggingLevel.ordinal() < openTelemetryLoggingLevel.ordinal(), 'OpenTelemetry logging level ordinal was incorrect'); + LoggerPlugin__mdt pluginConfiguration = mockConfigurations(openTelemetryLoggingLevel); + List logs = insertLogAndLogEntry(logEntryLoggingLevel, false); + Log__c log = logs.get(0); + System.Assert.isFalse(log.SendToOpenTelemetry__c); + LoggerTriggerableContext context = mockContext(System.TriggerOperation.BEFORE_INSERT, logs); + + OpenTelemetryLoggerPlugin plugin = new OpenTelemetryLoggerPlugin(); + plugin.execute(pluginConfiguration, context); + + System.Assert.isFalse(log.SendToOpenTelemetry__c, 'SendToOpenTelemetry__c incorrectly set to true'); + System.Assert.isNull(log.OpenTelemetryExportDate__c, 'OpenTelemetryExportDate__c was not null'); + } + + @IsTest + static void it_should_flag_log_to_send_opentelemetry_when_logging_level_is_met() { + System.LoggingLevel logEntryLoggingLevel = System.LoggingLevel.ERROR; + System.LoggingLevel openTelemetryLoggingLevel = System.LoggingLevel.WARN; + System.Assert.isTrue(logEntryLoggingLevel.ordinal() > openTelemetryLoggingLevel.ordinal(), 'OpenTelemetry logging level ordinal was incorrect'); + LoggerPlugin__mdt pluginConfiguration = mockConfigurations(openTelemetryLoggingLevel); + List logs = insertLogAndLogEntry(logEntryLoggingLevel, false); + Log__c log = logs.get(0); + System.Assert.isFalse(log.SendToOpenTelemetry__c); + LoggerTriggerableContext context = mockContext(System.TriggerOperation.BEFORE_INSERT, logs); + + OpenTelemetryLoggerPlugin plugin = new OpenTelemetryLoggerPlugin(); + plugin.execute(pluginConfiguration, context); + + log = logs.get(0); + System.Assert.isTrue(log.SendToOpenTelemetry__c, 'SendToOpenTelemetry__c incorrectly set to false'); + System.Assert.isNull(log.OpenTelemetryExportDate__c, 'OpenTelemetryExportDate__c was not null'); + } + + @IsTest + static void it_should_not_send_opentelemetry_for_log_when_logging_level_is_not_met() { + System.LoggingLevel logEntryLoggingLevel = System.LoggingLevel.WARN; + System.LoggingLevel openTelemetryLoggingLevel = System.LoggingLevel.ERROR; + System.Assert.isTrue(logEntryLoggingLevel.ordinal() < openTelemetryLoggingLevel.ordinal(), 'OpenTelemetry logging level ordinal was incorrect'); + LoggerPlugin__mdt pluginConfiguration = mockConfigurations(openTelemetryLoggingLevel); + List logs = insertLogAndLogEntry(logEntryLoggingLevel, false); + LoggerTriggerableContext context = mockContext(System.TriggerOperation.AFTER_UPDATE, logs); + LoggerMockDataCreator.MockHttpCallout calloutMock = LoggerMockDataCreator.createHttpCallout().setStatusCode(200); + System.Test.setMock(System.HttpCalloutMock.class, calloutMock); + + System.Test.startTest(); + OpenTelemetryLoggerPlugin plugin = new OpenTelemetryLoggerPlugin(); + plugin.execute(pluginConfiguration, context); + System.Assert.areEqual(0, System.Limits.getAsyncCalls(), 'The queueable job should not have been enqueued'); + System.Test.stopTest(); + + Log__c log = queryLogs(logEntryLoggingLevel).get(0); + System.Assert.isFalse(log.SendToOpenTelemetry__c, 'SendToOpenTelemetry__c incorrectly set to true'); + System.Assert.isNull(log.OpenTelemetryExportDate__c, 'OpenTelemetryExportDate__c was not null'); + System.Assert.isNull(calloutMock.request); + } + + @IsTest + static void it_should_send_opentelemetry_for_log_when_logging_level_is_met_for_error() { + System.LoggingLevel logEntryLoggingLevel = System.LoggingLevel.ERROR; + LoggerPlugin__mdt pluginConfiguration = mockConfigurations(logEntryLoggingLevel); + List logs = insertLogAndLogEntry(logEntryLoggingLevel, true); + LoggerTriggerableContext context = mockContext(System.TriggerOperation.AFTER_UPDATE, logs); + LoggerMockDataCreator.MockHttpCallout calloutMock = LoggerMockDataCreator.createHttpCallout().setStatusCode(200); + System.Test.setMock(System.HttpCalloutMock.class, calloutMock); + + System.Test.startTest(); + OpenTelemetryLoggerPlugin plugin = new OpenTelemetryLoggerPlugin(); + plugin.execute(pluginConfiguration, context); + System.Test.stopTest(); + + validateOpenTelemetryPayload(calloutMock, logs.get(0), 'ERROR'); + } + + @IsTest + static void it_should_send_opentelemetry_for_log_when_logging_level_is_met_for_warn() { + System.LoggingLevel logEntryLoggingLevel = System.LoggingLevel.WARN; + LoggerPlugin__mdt pluginConfiguration = mockConfigurations(logEntryLoggingLevel); + List logs = insertLogAndLogEntry(logEntryLoggingLevel, true); + LoggerTriggerableContext context = mockContext(System.TriggerOperation.AFTER_UPDATE, logs); + LoggerMockDataCreator.MockHttpCallout calloutMock = LoggerMockDataCreator.createHttpCallout().setStatusCode(200); + System.Test.setMock(System.HttpCalloutMock.class, calloutMock); + + System.Test.startTest(); + OpenTelemetryLoggerPlugin plugin = new OpenTelemetryLoggerPlugin(); + plugin.execute(pluginConfiguration, context); + System.Test.stopTest(); + + validateOpenTelemetryPayload(calloutMock, logs.get(0), 'WARN'); + } + + @IsTest + static void it_should_send_opentelemetry_for_log_when_logging_level_is_met_for_info() { + System.LoggingLevel logEntryLoggingLevel = System.LoggingLevel.INFO; + LoggerPlugin__mdt pluginConfiguration = mockConfigurations(logEntryLoggingLevel); + List logs = insertLogAndLogEntry(logEntryLoggingLevel, true); + LoggerTriggerableContext context = mockContext(System.TriggerOperation.AFTER_UPDATE, logs); + LoggerMockDataCreator.MockHttpCallout calloutMock = LoggerMockDataCreator.createHttpCallout().setStatusCode(200); + System.Test.setMock(System.HttpCalloutMock.class, calloutMock); + + System.Test.startTest(); + OpenTelemetryLoggerPlugin plugin = new OpenTelemetryLoggerPlugin(); + plugin.execute(pluginConfiguration, context); + System.Test.stopTest(); + + validateOpenTelemetryPayload(calloutMock, logs.get(0), 'INFO'); + } + + @IsTest + static void it_should_send_opentelemetry_for_log_when_logging_level_is_met_for_debug() { + System.LoggingLevel logEntryLoggingLevel = System.LoggingLevel.DEBUG; + LoggerPlugin__mdt pluginConfiguration = mockConfigurations(logEntryLoggingLevel); + List logs = insertLogAndLogEntry(logEntryLoggingLevel, true); + LoggerTriggerableContext context = mockContext(System.TriggerOperation.AFTER_UPDATE, logs); + LoggerMockDataCreator.MockHttpCallout calloutMock = LoggerMockDataCreator.createHttpCallout().setStatusCode(200); + System.Test.setMock(System.HttpCalloutMock.class, calloutMock); + + System.Test.startTest(); + OpenTelemetryLoggerPlugin plugin = new OpenTelemetryLoggerPlugin(); + plugin.execute(pluginConfiguration, context); + System.Test.stopTest(); + + validateOpenTelemetryPayload(calloutMock, logs.get(0), 'DEBUG'); + } + + @IsTest + static void it_should_send_opentelemetry_for_log_when_logging_level_is_met_for_fine() { + System.LoggingLevel logEntryLoggingLevel = System.LoggingLevel.FINE; + LoggerPlugin__mdt pluginConfiguration = mockConfigurations(logEntryLoggingLevel); + List logs = insertLogAndLogEntry(logEntryLoggingLevel, true); + LoggerTriggerableContext context = mockContext(System.TriggerOperation.AFTER_UPDATE, logs); + LoggerMockDataCreator.MockHttpCallout calloutMock = LoggerMockDataCreator.createHttpCallout().setStatusCode(200); + System.Test.setMock(System.HttpCalloutMock.class, calloutMock); + + System.Test.startTest(); + OpenTelemetryLoggerPlugin plugin = new OpenTelemetryLoggerPlugin(); + plugin.execute(pluginConfiguration, context); + System.Test.stopTest(); + + validateOpenTelemetryPayload(calloutMock, logs.get(0), 'FINE'); + } + + @IsTest + static void it_should_send_opentelemetry_for_log_when_logging_level_is_met_for_finer() { + System.LoggingLevel logEntryLoggingLevel = System.LoggingLevel.FINER; + LoggerPlugin__mdt pluginConfiguration = mockConfigurations(logEntryLoggingLevel); + List logs = insertLogAndLogEntry(logEntryLoggingLevel, true); + LoggerTriggerableContext context = mockContext(System.TriggerOperation.AFTER_UPDATE, logs); + LoggerMockDataCreator.MockHttpCallout calloutMock = LoggerMockDataCreator.createHttpCallout().setStatusCode(200); + System.Test.setMock(System.HttpCalloutMock.class, calloutMock); + + System.Test.startTest(); + OpenTelemetryLoggerPlugin plugin = new OpenTelemetryLoggerPlugin(); + plugin.execute(pluginConfiguration, context); + System.Test.stopTest(); + + validateOpenTelemetryPayload(calloutMock, logs.get(0), 'FINER'); + } + + @IsTest + static void it_should_send_opentelemetry_for_log_when_logging_level_is_met_for_finest() { + System.LoggingLevel logEntryLoggingLevel = System.LoggingLevel.FINEST; + LoggerPlugin__mdt pluginConfiguration = mockConfigurations(logEntryLoggingLevel); + List logs = insertLogAndLogEntry(logEntryLoggingLevel, true); + LoggerTriggerableContext context = mockContext(System.TriggerOperation.AFTER_UPDATE, logs); + LoggerMockDataCreator.MockHttpCallout calloutMock = LoggerMockDataCreator.createHttpCallout().setStatusCode(200); + System.Test.setMock(System.HttpCalloutMock.class, calloutMock); + + System.Test.startTest(); + OpenTelemetryLoggerPlugin plugin = new OpenTelemetryLoggerPlugin(); + plugin.execute(pluginConfiguration, context); + System.Test.stopTest(); + + validateOpenTelemetryPayload(calloutMock, logs.get(0), 'FINEST'); + } + + @IsTest + static void it_should_include_auth_header_when_configured() { + System.LoggingLevel logEntryLoggingLevel = System.LoggingLevel.ERROR; + String authHeader = 'Authorization: Bearer test-token-123'; + LoggerPlugin__mdt pluginConfiguration = mockConfigurations(logEntryLoggingLevel, authHeader); + List logs = insertLogAndLogEntry(logEntryLoggingLevel, true); + LoggerTriggerableContext context = mockContext(System.TriggerOperation.AFTER_UPDATE, logs); + LoggerMockDataCreator.MockHttpCallout calloutMock = LoggerMockDataCreator.createHttpCallout().setStatusCode(200); + System.Test.setMock(System.HttpCalloutMock.class, calloutMock); + + System.Test.startTest(); + OpenTelemetryLoggerPlugin plugin = new OpenTelemetryLoggerPlugin(); + plugin.execute(pluginConfiguration, context); + System.Test.stopTest(); + + System.Assert.isNotNull(calloutMock.request, 'HTTP request should have been made'); + System.Assert.areEqual('Bearer test-token-123', calloutMock.request.getHeader('Authorization'), 'Authorization header should be set'); + } + + @IsTest + static void it_should_handle_exception_data_in_log_entry() { + System.LoggingLevel logEntryLoggingLevel = System.LoggingLevel.ERROR; + LoggerPlugin__mdt pluginConfiguration = mockConfigurations(logEntryLoggingLevel); + List logs = insertLogWithException(logEntryLoggingLevel); + LoggerTriggerableContext context = mockContext(System.TriggerOperation.AFTER_UPDATE, logs); + LoggerMockDataCreator.MockHttpCallout calloutMock = LoggerMockDataCreator.createHttpCallout().setStatusCode(200); + System.Test.setMock(System.HttpCalloutMock.class, calloutMock); + + System.Test.startTest(); + OpenTelemetryLoggerPlugin plugin = new OpenTelemetryLoggerPlugin(); + plugin.execute(pluginConfiguration, context); + System.Test.stopTest(); + + System.Assert.isNotNull(calloutMock.request, 'HTTP request should have been made'); + String requestBody = calloutMock.request.getBody(); + System.Assert.isTrue(requestBody.contains('exception.type'), 'Should include exception type'); + System.Assert.isTrue(requestBody.contains('NullPointerException'), 'Should include exception type value'); + } + + private static LoggerPlugin__mdt mockConfigurations(System.LoggingLevel notificationLoggingLevel) { + return mockConfigurations(notificationLoggingLevel, null); + } + + private static LoggerPlugin__mdt mockConfigurations(System.LoggingLevel notificationLoggingLevel, String authHeader) { + String mockEndpoint = 'https://fake.otel.example.com/v1/logs'; + LoggerTestConfigurator.setMock(new LoggerParameter__mdt(DeveloperName = 'OpenTelemetryEndpoint', Value__c = mockEndpoint)); + LoggerTestConfigurator.setMock(new LoggerParameter__mdt(DeveloperName = 'OpenTelemetryServiceName', Value__c = 'Salesforce')); + LoggerTestConfigurator.setMock(new LoggerParameter__mdt(DeveloperName = 'OpenTelemetryServiceVersion', Value__c = '1.0.0')); + LoggerTestConfigurator.setMock( + new LoggerParameter__mdt(DeveloperName = 'OpenTelemetryNotificationLoggingLevel', Value__c = notificationLoggingLevel.name()) + ); + if (String.isNotBlank(authHeader)) { + LoggerTestConfigurator.setMock(new LoggerParameter__mdt(DeveloperName = 'OpenTelemetryAuthHeader', Value__c = authHeader)); + } + System.Assert.areEqual(mockEndpoint, LoggerParameter.getString('OpenTelemetryEndpoint', null)); + System.Assert.areEqual(mockEndpoint, OpenTelemetryLoggerPlugin.ENDPOINT); + System.Assert.areEqual(notificationLoggingLevel.name(), LoggerParameter.getString('OpenTelemetryNotificationLoggingLevel', null)); + System.Assert.areEqual(notificationLoggingLevel, OpenTelemetryLoggerPlugin.NOTIFICATION_LOGGING_LEVEL); + + LoggerPlugin__mdt pluginConfiguration = new LoggerPlugin__mdt( + DeveloperName = 'OpenTelemetryPlugin', + IsEnabled__c = true, + SObjectHandlerApexClass__c = OpenTelemetryLoggerPlugin.class.getName() + ); + LoggerTestConfigurator.setMock(pluginConfiguration); + return pluginConfiguration; + } + + private static LoggerTriggerableContext mockContext(System.TriggerOperation operationType, List logs) { + return new LoggerTriggerableContext(Schema.Log__c.SObjectType, operationType, logs, new Map(logs), new Map(logs)); + } + + private static List insertLogAndLogEntry(System.LoggingLevel logEntryLoggingLevel, Boolean sendToOpenTelemetry) { + LoggerSObjectHandler.shouldExecute(false); + Log__c log = new Log__c(LoggedBy__c = System.UserInfo.getUserId(), SendToOpenTelemetry__c = sendToOpenTelemetry, TransactionId__c = '1234-5678-90ab'); + insert log; + LogEntry__c logEntry = new LogEntry__c( + ExceptionStackTrace__c = 'Some exception stack trace', + Log__c = log.Id, + LoggingLevel__c = logEntryLoggingLevel.name(), + LoggingLevelOrdinal__c = logEntryLoggingLevel.ordinal(), + Message__c = 'Test log message', + StackTrace__c = 'A stack trace', + Timestamp__c = System.now() + ); + insert logEntry; + return queryLogs(logEntryLoggingLevel); + } + + private static List insertLogWithException(System.LoggingLevel logEntryLoggingLevel) { + LoggerSObjectHandler.shouldExecute(false); + Log__c log = new Log__c(LoggedBy__c = System.UserInfo.getUserId(), SendToOpenTelemetry__c = true, TransactionId__c = '1234-5678-90ab'); + insert log; + LogEntry__c logEntry = new LogEntry__c( + ExceptionType__c = 'NullPointerException', + ExceptionMessage__c = 'Attempt to de-reference a null object', + ExceptionStackTrace__c = 'Class.MyClass.myMethod: line 42', + Log__c = log.Id, + LoggingLevel__c = logEntryLoggingLevel.name(), + LoggingLevelOrdinal__c = logEntryLoggingLevel.ordinal(), + Message__c = 'Test log message with exception', + StackTrace__c = 'A stack trace', + Timestamp__c = System.now() + ); + insert logEntry; + return queryLogs(logEntryLoggingLevel); + } + + private static void validateOpenTelemetryPayload(LoggerMockDataCreator.MockHttpCallout calloutMock, Log__c log, String expectedLevel) { + System.Assert.isNotNull(calloutMock.request, 'HTTP request should have been made'); + String requestBody = calloutMock.request.getBody(); + + OpenTelemetryLoggerPlugin.OtlpPayload payload = (OpenTelemetryLoggerPlugin.OtlpPayload) System.JSON.deserialize( + requestBody, + OpenTelemetryLoggerPlugin.OtlpPayload.class + ); + + System.Assert.isNotNull(payload.resourceLogs, 'resourceLogs should not be null'); + System.Assert.areEqual(1, payload.resourceLogs.size(), 'Should have one resourceLog'); + System.Assert.isNotNull(payload.resourceLogs[0].scopeLogs, 'scopeLogs should not be null'); + System.Assert.areEqual(1, payload.resourceLogs[0].scopeLogs.size(), 'Should have one scopeLog'); + System.Assert.isNotNull(payload.resourceLogs[0].scopeLogs[0].logRecords, 'logRecords should not be null'); + System.Assert.isTrue(payload.resourceLogs[0].scopeLogs[0].logRecords.size() > 0, 'Should have at least one logRecord'); + + OpenTelemetryLoggerPlugin.LogRecord firstRecord = payload.resourceLogs[0].scopeLogs[0].logRecords[0]; + System.Assert.areEqual(expectedLevel, firstRecord.severityText, 'Severity text should match expected level'); + System.Assert.isNotNull(firstRecord.body, 'Body should not be null'); + System.Assert.isTrue(String.isNotBlank(firstRecord.body.stringValue), 'Body should have a message'); + } + + @SuppressWarnings('PMD.UnusedLocalVariable') + private static List queryLogs(System.LoggingLevel notificationLoggingLevel) { + Integer notificationLoggingLevelOrdinal = notificationLoggingLevel.ordinal(); + String logEntryChildQuery = + '\n(' + + '\nSELECT Id, LoggingLevel__c, LoggingLevelOrdinal__c, Message__c, ExceptionType__c, ExceptionMessage__c,' + + '\n ExceptionStackTrace__c, StackTrace__c, Timestamp__c' + + '\nFROM LogEntries__r' + + '\nWHERE LoggingLevelOrdinal__c >= :notificationLoggingLevelOrdinal' + + '\nORDER BY Timestamp__c ASC' + + '\n)'; + + String query = + 'SELECT Id, Name, TransactionId__c, OrganizationId__c, SendToOpenTelemetry__c, OpenTelemetryExportDate__c, MaxLogEntryLoggingLevelOrdinal__c, ' + + 'LoggedBy__r.Username, ' + + logEntryChildQuery + + ' FROM Log__c'; + return (List) System.Database.query(query); + } +} + diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls-meta.xml b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls-meta.xml new file mode 100644 index 000000000..a89d3abfc --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls-meta.xml @@ -0,0 +1,6 @@ + + + 62.0 + Active + + diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryAuthHeader.md-meta.xml b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryAuthHeader.md-meta.xml new file mode 100644 index 000000000..c753ee972 --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryAuthHeader.md-meta.xml @@ -0,0 +1,14 @@ + + + + false + + Description__c + Optional authentication header for OpenTelemetry endpoint. Format: 'HeaderName: HeaderValue' (e.g., 'Authorization: Bearer your-token' or 'x-api-key: your-key'). Leave blank if using Named Credentials or if no authentication is required + + + Value__c + + + + diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryEndpoint.md-meta.xml b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryEndpoint.md-meta.xml new file mode 100644 index 000000000..6aeef1d3d --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryEndpoint.md-meta.xml @@ -0,0 +1,14 @@ + + + + false + + Description__c + The OTLP endpoint URL for sending logs (e.g., https://otel-collector.example.com/v1/logs). Can also use Named Credentials format: callout:YourNamedCredential + + + Value__c + + + + diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryNotificationLoggingLevel.md-meta.xml b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryNotificationLoggingLevel.md-meta.xml new file mode 100644 index 000000000..959b8e78f --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryNotificationLoggingLevel.md-meta.xml @@ -0,0 +1,14 @@ + + + + false + + Description__c + The logging level name that triggers logs to be sent to OpenTelemetry. Possible logging levels are: ERROR, WARN, INFO, DEBUG, FINE, FINER, or FINEST + + + Value__c + WARN + + + diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryServiceName.md-meta.xml b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryServiceName.md-meta.xml new file mode 100644 index 000000000..a69fca5fa --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryServiceName.md-meta.xml @@ -0,0 +1,14 @@ + + + + false + + Description__c + The service.name attribute value to include in OpenTelemetry resource attributes. Defaults to 'Salesforce' if not specified + + + Value__c + Salesforce + + + diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryServiceVersion.md-meta.xml b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryServiceVersion.md-meta.xml new file mode 100644 index 000000000..ce1c67b98 --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryServiceVersion.md-meta.xml @@ -0,0 +1,14 @@ + + + + false + + Description__c + The service.version attribute value to include in OpenTelemetry resource attributes. Defaults to '1.0.0' if not specified + + + Value__c + 1.0.0 + + + diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerPlugin.OpenTelemetry.md-meta.xml b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerPlugin.OpenTelemetry.md-meta.xml new file mode 100644 index 000000000..83b9ffa08 --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerPlugin.OpenTelemetry.md-meta.xml @@ -0,0 +1,64 @@ + + + + false + + BatchPurgerApexClass__c + + + + BatchPurgerExecutionOrder__c + + + + BatchPurgerFlowName__c + + + + Description__c + Adds an OpenTelemetry integration for Nebula Logger. + +Any logs with MaxLogEntryLoggingLevelOrdinal__c >= the parameter 'OpenTelemetryNotificationLoggingLevel' will send log records to the configured OpenTelemetry endpoint using OTLP JSON format + + + ExecutionOrder__c + + + + IsEnabled__c + true + + + Link__c + https://github.com/jongpie/NebulaLogger/tree/main/nebula-logger/plugins/opentelemetry + + + PluginApiName__c + + + + PluginType__c + Apex + + + SObjectHandlerApexClass__c + OpenTelemetryLoggerPlugin + + + SObjectHandlerExecutionOrder__c + + + + SObjectHandlerFlowName__c + + + + SObjectType__c + + + + VersionNumber__c + v1.0.0 + + + diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/permissionsets/LoggerOpenTelemetryPluginAdmin.permissionset-meta.xml b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/permissionsets/LoggerOpenTelemetryPluginAdmin.permissionset-meta.xml new file mode 100644 index 000000000..37aac3705 --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/permissionsets/LoggerOpenTelemetryPluginAdmin.permissionset-meta.xml @@ -0,0 +1,21 @@ + + + + OpenTelemetryLoggerPlugin + true + + Provides additional access for the Logger OpenTelemetry plugin + + true + Log__c.SendToOpenTelemetry__c + true + + + true + Log__c.OpenTelemetryExportDate__c + true + + false + + + From 56ba07fb01f721f0a7ee2525fd76240f92daa9c0 Mon Sep 17 00:00:00 2001 From: howardyoo Date: Mon, 24 Nov 2025 19:10:16 -0600 Subject: [PATCH 02/10] fixed an error of incorrect syntax --- .../plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls index bdd466bb9..fd1b0a1e9 100644 --- a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls @@ -426,7 +426,7 @@ public without sharing class OpenTelemetryLoggerPlugin implements LoggerPlugin.T private class Scope { public String name; public String version; - public List; + public List attributes; } @SuppressWarnings('PMD.FieldNamingConventions, PMD.VariableNamingConventions') From 40758d4a747d5a9c59f629d1eef73f874baa59f2 Mon Sep 17 00:00:00 2001 From: howardyoo Date: Mon, 24 Nov 2025 21:50:27 -0600 Subject: [PATCH 03/10] bug fix that duplicated log exports --- .../classes/OpenTelemetryLoggerPlugin.cls | 3 +- .../OpenTelemetryLoggerPlugin_Tests.cls | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls index fd1b0a1e9..ff7a44717 100644 --- a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls @@ -119,7 +119,8 @@ public without sharing class OpenTelemetryLoggerPlugin implements LoggerPlugin.T private void sendAsyncOpenTelemetryLogs() { List logsToSend = new List(); for (Log__c log : this.logs) { - if (log.SendToOpenTelemetry__c) { + // Only send logs that are flagged for export AND have not already been exported + if (log.SendToOpenTelemetry__c && log.OpenTelemetryExportDate__c == null) { logsToSend.add(log); } } diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls index 0cb9ca776..c6a41bc65 100644 --- a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls @@ -224,6 +224,38 @@ private class OpenTelemetryLoggerPlugin_Tests { System.Assert.isTrue(requestBody.contains('NullPointerException'), 'Should include exception type value'); } + @IsTest + static void it_should_not_send_logs_that_have_already_been_exported() { + System.LoggingLevel logEntryLoggingLevel = System.LoggingLevel.ERROR; + LoggerPlugin__mdt pluginConfiguration = mockConfigurations(logEntryLoggingLevel); + List logs = insertLogAndLogEntry(logEntryLoggingLevel, true); + + // Simulate that the log has already been exported by setting the export date + logs[0].OpenTelemetryExportDate__c = System.now(); + update logs[0]; + + // Re-query to ensure we have the updated record + logs = queryLogs(logEntryLoggingLevel); + + LoggerTriggerableContext context = mockContext(System.TriggerOperation.AFTER_UPDATE, logs); + LoggerMockDataCreator.MockHttpCallout calloutMock = LoggerMockDataCreator.createHttpCallout().setStatusCode(200); + System.Test.setMock(System.HttpCalloutMock.class, calloutMock); + + System.Test.startTest(); + OpenTelemetryLoggerPlugin plugin = new OpenTelemetryLoggerPlugin(); + plugin.execute(pluginConfiguration, context); + System.Assert.areEqual(0, System.Limits.getAsyncCalls(), 'The queueable job should not have been enqueued for already-exported logs'); + System.Test.stopTest(); + + // Verify no HTTP callout was made + System.Assert.isNull(calloutMock.request, 'HTTP request should NOT have been made for already-exported logs'); + + // Verify the export date hasn't changed + Log__c log = queryLogs(logEntryLoggingLevel).get(0); + System.Assert.isTrue(log.SendToOpenTelemetry__c, 'SendToOpenTelemetry__c should still be true'); + System.Assert.isNotNull(log.OpenTelemetryExportDate__c, 'OpenTelemetryExportDate__c should remain populated'); + } + private static LoggerPlugin__mdt mockConfigurations(System.LoggingLevel notificationLoggingLevel) { return mockConfigurations(notificationLoggingLevel, null); } From 947a7b223506d79563adbcfd905427dedcfdeca4 Mon Sep 17 00:00:00 2001 From: howardyoo Date: Wed, 26 Nov 2025 18:46:44 -0600 Subject: [PATCH 04/10] version upgrade on the api Version 62.0 -> 64.0 --- .../classes/OpenTelemetryLoggerPlugin.cls-meta.xml | 2 +- .../classes/OpenTelemetryLoggerPlugin_Tests.cls-meta.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls-meta.xml b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls-meta.xml index a89d3abfc..fad4aeb3e 100644 --- a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls-meta.xml +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls-meta.xml @@ -1,6 +1,6 @@ - 62.0 + 64.0 Active diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls-meta.xml b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls-meta.xml index a89d3abfc..fad4aeb3e 100644 --- a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls-meta.xml +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls-meta.xml @@ -1,6 +1,6 @@ - 62.0 + 64.0 Active From e4335b05710f69e6cb63dfdf44a1e2bed28e1bde Mon Sep 17 00:00:00 2001 From: howardyoo Date: Wed, 26 Nov 2025 18:59:15 -0600 Subject: [PATCH 05/10] implemented system finalizer interface to handle for unhandled errors can be logged. --- .../classes/OpenTelemetryLoggerPlugin.cls | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls index ff7a44717..8faeb7c94 100644 --- a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls @@ -8,7 +8,7 @@ * @description Optional plugin that integrates with OpenTelemetry to send log records using the OTLP format */ @SuppressWarnings('PMD.ApexCRUDViolation, PMD.CyclomaticComplexity, PMD.ExcessivePublicCount') -public without sharing class OpenTelemetryLoggerPlugin implements LoggerPlugin.Triggerable, System.Queueable, Database.AllowsCallouts { +public without sharing class OpenTelemetryLoggerPlugin implements LoggerPlugin.Triggerable, System.Queueable, System.Finalizer, Database.AllowsCallouts { @TestVisible private static final String ENDPOINT = LoggerParameter.getString('OpenTelemetryEndpoint', null); @TestVisible @@ -72,8 +72,9 @@ public without sharing class OpenTelemetryLoggerPlugin implements LoggerPlugin.T } List sentLogs = new List(); - List unsentLogs = new List(); - + List unsentLogs = new List(); + DateTime exportTimeNow = System.now(); + for (Log__c log : this.logs) { if (System.Limits.getCallouts() == System.Limits.getLimitCallouts()) { // If there are too many logs to send in the same transaction @@ -92,7 +93,7 @@ public without sharing class OpenTelemetryLoggerPlugin implements LoggerPlugin.T Logger.finest('Sent log entries to OpenTelemetry endpoint').setHttpResponseDetails(response); } - log.OpenTelemetryExportDate__c = System.now(); + log.OpenTelemetryExportDate__c = exportTimeNow; sentLogs.add(log); } Logger.saveLog(); @@ -100,8 +101,40 @@ public without sharing class OpenTelemetryLoggerPlugin implements LoggerPlugin.T // If any logs couldn't be sent due to governor limits, start a new instance of the job if (unsentLogs.size() > 0) { - System.enqueueJob(new OpenTelemetryLoggerPlugin(unsentLogs)); + Id jobId = System.enqueueJob(new OpenTelemetryLoggerPlugin(unsentLogs)); + System.attachFinalizer(new OpenTelemetryLoggerPlugin(unsentLogs)); + } + } + + /** + * @description Handles errors that occur during queueable execution. Required by the System.Finalizer interface. + * @param finalizerContext Context of the current finalizer instance containing error information. + */ + public void execute(System.FinalizerContext finalizerContext) { + if (finalizerContext.getResult() == System.ParentJobResult.SUCCESS) { + return; } + + // Log the error that occurred during queueable execution + String errorMessage = 'OpenTelemetry queueable job failed'; + Exception jobException = finalizerContext.getException(); + + if (jobException != null) { + Logger.error(errorMessage, jobException); + } else { + Logger.error(errorMessage); + } + + // Add context about which logs were being processed + if (this.logs != null && !this.logs.isEmpty()) { + List logIds = new List(); + for (Log__c log : this.logs) { + logIds.add(log.Id); + } + Logger.error('Failed to send ' + this.logs.size() + ' log(s) to OpenTelemetry. Log IDs: ' + String.join(logIds, ', ')); + } + + Logger.saveLog(); } private void flagLogsForOpenTelemetryExport() { @@ -127,7 +160,8 @@ public without sharing class OpenTelemetryLoggerPlugin implements LoggerPlugin.T // Since plugins are called from trigger handlers, and triggers can't make callouts // run this class as a queueable (async) job if (logsToSend.isEmpty() == false) { - System.enqueueJob(new OpenTelemetryLoggerPlugin(logsToSend)); + Id jobId = System.enqueueJob(new OpenTelemetryLoggerPlugin(logsToSend)); + System.attachFinalizer(new OpenTelemetryLoggerPlugin(logsToSend)); } } From cce4a08f634f13fcdedec551ec3e50a254fc3323 Mon Sep 17 00:00:00 2001 From: howardyoo Date: Wed, 26 Nov 2025 20:27:47 -0600 Subject: [PATCH 06/10] minor bug fix --- .../classes/OpenTelemetryLoggerPlugin.cls | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls index 8faeb7c94..d853f9dd5 100644 --- a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls @@ -64,6 +64,9 @@ public without sharing class OpenTelemetryLoggerPlugin implements LoggerPlugin.T * @param queueableContext Context of the current queueable instance. */ public void execute(System.QueueableContext queueableContext) { + // Attach finalizer at the start to handle any errors during execution + System.attachFinalizer(this); + // Since this runs in an async context, requery the logs just in case any field values have changed this.requeryLogs(); @@ -73,7 +76,7 @@ public without sharing class OpenTelemetryLoggerPlugin implements LoggerPlugin.T List sentLogs = new List(); List unsentLogs = new List(); - DateTime exportTimeNow = System.now(); + DateTime exportTime = System.now(); for (Log__c log : this.logs) { if (System.Limits.getCallouts() == System.Limits.getLimitCallouts()) { @@ -93,7 +96,7 @@ public without sharing class OpenTelemetryLoggerPlugin implements LoggerPlugin.T Logger.finest('Sent log entries to OpenTelemetry endpoint').setHttpResponseDetails(response); } - log.OpenTelemetryExportDate__c = exportTimeNow; + log.OpenTelemetryExportDate__c = exportTime; sentLogs.add(log); } Logger.saveLog(); @@ -101,8 +104,7 @@ public without sharing class OpenTelemetryLoggerPlugin implements LoggerPlugin.T // If any logs couldn't be sent due to governor limits, start a new instance of the job if (unsentLogs.size() > 0) { - Id jobId = System.enqueueJob(new OpenTelemetryLoggerPlugin(unsentLogs)); - System.attachFinalizer(new OpenTelemetryLoggerPlugin(unsentLogs)); + System.enqueueJob(new OpenTelemetryLoggerPlugin(unsentLogs)); } } @@ -159,9 +161,9 @@ public without sharing class OpenTelemetryLoggerPlugin implements LoggerPlugin.T } // Since plugins are called from trigger handlers, and triggers can't make callouts // run this class as a queueable (async) job + // The finalizer will be attached inside the queueable's execute method if (logsToSend.isEmpty() == false) { - Id jobId = System.enqueueJob(new OpenTelemetryLoggerPlugin(logsToSend)); - System.attachFinalizer(new OpenTelemetryLoggerPlugin(logsToSend)); + System.enqueueJob(new OpenTelemetryLoggerPlugin(logsToSend)); } } From 382a0eeadc24f99f8d61dde2686f723af2eae4c3 Mon Sep 17 00:00:00 2001 From: howardyoo Date: Wed, 26 Nov 2025 21:43:56 -0600 Subject: [PATCH 07/10] Support for named credentials in Auth header --- .../plugins/opentelemetry/INSTALLATION.md | 48 +++- nebula-logger/plugins/opentelemetry/README.md | 218 ++++++++++++++++-- .../classes/OpenTelemetryLoggerPlugin.cls | 7 +- 3 files changed, 237 insertions(+), 36 deletions(-) diff --git a/nebula-logger/plugins/opentelemetry/INSTALLATION.md b/nebula-logger/plugins/opentelemetry/INSTALLATION.md index 7dbf624a6..bfbadf90e 100644 --- a/nebula-logger/plugins/opentelemetry/INSTALLATION.md +++ b/nebula-logger/plugins/opentelemetry/INSTALLATION.md @@ -18,7 +18,11 @@ sf project deploy start --source-dir nebula-logger/plugins/opentelemetry/plugin Or deploy the metadata using your preferred deployment method (VS Code, Workbench, Change Sets, etc.) -### 2. Configure Remote Site Settings (if not using Named Credentials) +### 2. Configure Remote Site Settings OR Named Credentials + +You must configure either Remote Site Settings (Option A) OR Named Credentials (Option B - Recommended). + +**Option A: Remote Site Settings** (if NOT using Named Credentials) 1. Go to **Setup** → **Remote Site Settings** 2. Click **New Remote Site** @@ -28,13 +32,25 @@ Or deploy the metadata using your preferred deployment method (VS Code, Workbenc - Check **Active** 4. Click **Save** +**Option B: Named Credentials** (Recommended - More Secure) + +Named Credentials provide better security and don't require Remote Site Settings. See the [detailed Named Credentials setup guide in the main README](README.md#setting-up-named-credentials) for complete instructions. + +Quick setup: +1. **Setup** → **Named Credentials** → **External Credentials** → **New** +2. Create External Credential with appropriate authentication protocol +3. Add a Principal with your credentials (API key, token, etc.) +4. **Named Credentials** → **New** → Link to External Credential and add endpoint URL +5. Create/edit a permission set to grant External Credential Principal Access +6. Assign permission set to **Automated Process** user + ### 3. Configure Logger Parameters 1. Go to **Setup** → **Custom Metadata Types** → **Logger Parameter** → **Manage Records** 2. Click on **OpenTelemetry Endpoint**: - - Set **Value**: Your OTLP endpoint URL (e.g., `https://otel-collector.example.com/v1/logs`) - - Or use Named Credential: `callout:YourNamedCredential` + - **Using Direct URL**: Set **Value** to your OTLP endpoint URL (e.g., `https://otel-collector.example.com/v1/logs`) + - **Using Named Credential** (Recommended): Set **Value** to `callout:YourNamedCredentialAPIName` (e.g., `callout:OpenTelemetryCollector`) - Click **Save** 3. Click on **OpenTelemetry Notification Logging Level**: @@ -49,11 +65,14 @@ Or deploy the metadata using your preferred deployment method (VS Code, Workbenc - Set **Value**: Version identifier (e.g., `1.0.0`, `2023-Q4`) - Default: `1.0.0` -6. (Optional) Configure **OpenTelemetry Auth Header** (if not using Named Credentials): - - Set **Value**: Authentication header in format `HeaderName: HeaderValue` - - Example: `Authorization: Bearer your-api-token` - - Or: `x-api-key: your-api-key` - - Leave blank if using Named Credentials +6. (Optional) Configure **OpenTelemetry Auth Header**: + - **Format**: `HeaderName: HeaderValue` (e.g., `x-api-key: your-key` or `Authorization: Bearer your-token`) + - **Using Named Credentials**: To securely store credentials, use merge fields like `{!$Credential.YourNamedCredentialName}` + - Example: `x-api-key: {!$Credential.OpenTelemetryAuth}` + - Salesforce will automatically resolve the merge field at runtime + - **Direct value** (less secure): Enter the auth header directly (e.g., `x-api-key: hardcoded-key`) + - **If no authentication is required**: Leave this blank + - Click **Save** ### 4. Enable the Plugin @@ -116,9 +135,18 @@ Then verify: ### Common Issues -- **401 Unauthorized**: Check your authentication configuration (Auth Header or Named Credentials) +- **401 Unauthorized**: + - Check your authentication configuration + - Verify the Auth Header parameter is in the correct format: `HeaderName: HeaderValue` + - If using Named Credential merge fields (e.g., `{!$Credential.Name}`), ensure the credential exists and is accessible + - Verify the Named Credential's External Credential Principal has correct credentials + - Verify "Generate Authorization Header" is checked on the Named Credential if using Bearer token authentication - **404 Not Found**: Verify the endpoint URL includes the full path (e.g., `/v1/logs`) -- **No callout**: Verify Remote Site Settings are configured for your endpoint domain +- **No callout**: + - If using direct URL: Verify Remote Site Settings are configured for your endpoint domain + - If using Named Credentials: Verify the Automated Process user has access to the External Credential Principal (via permission set) +- **"You do not have the level of access necessary"**: The Automated Process user needs the permission set with External Credential Principal Access assigned +- **Auth header not working**: Ensure the parameter value is in the exact format `HeaderName: HeaderValue` with a colon separator --- diff --git a/nebula-logger/plugins/opentelemetry/README.md b/nebula-logger/plugins/opentelemetry/README.md index 2cc4b145b..655951682 100644 --- a/nebula-logger/plugins/opentelemetry/README.md +++ b/nebula-logger/plugins/opentelemetry/README.md @@ -72,8 +72,8 @@ The endpoint URL should be the OTLP HTTP endpoint, typically ending in `/v1/logs 3. Go to Setup --> Custom Metadata Types --> Logger Parameters. Configure the following parameters: - **Parameter 'OpenTelemetry Endpoint'** - You can configure this endpoint in 1 of 2 ways: - - Easier but less secure: Paste the OTLP endpoint URL into the `Value__c` field and save the Parameter record - - More secure: Create a new Named Credential ([see section below for step-by-step instructions](#setting-up-named-credentials)), using the endpoint URL. Within the Parameter 'OpenTelemetry Endpoint', enter `callout:` into the `Value__c` field and save the Parameter record + - **Option 1 - Direct URL** (Easier but less secure): Paste the OTLP endpoint URL into the `Value__c` field (e.g., `https://otel-collector.example.com/v1/logs`) and save the Parameter record + - **Option 2 - Named Credentials** (Recommended, more secure): Create a new Named Credential ([see section below for step-by-step instructions](#setting-up-named-credentials)), using the endpoint URL. Within the Parameter 'OpenTelemetry Endpoint', enter `callout:` into the `Value__c` field (e.g., `callout:OpenTelemetryCollector`) and save the Parameter record - **Parameter 'OpenTelemetry Notification Logging Level'** - Set the desired logging level value that should trigger logs to be exported to OpenTelemetry. It controls which logging level (ERROR, WARN, INFO, DEBUG, FINE, FINER, or FINEST) will trigger the exports. @@ -81,7 +81,7 @@ The endpoint URL should be the OTLP HTTP endpoint, typically ending in `/v1/logs - **Parameter 'OpenTelemetry Service Version'** (Optional) - Set the service version. Defaults to '1.0.0' if not specified. - - **Parameter 'OpenTelemetry Auth Header'** (Optional) - If your endpoint requires authentication and you're not using Named Credentials, you can specify the authentication header here in the format `HeaderName: HeaderValue` (e.g., `Authorization: Bearer your-token` or `x-api-key: your-key`). Leave blank if using Named Credentials or if no authentication is required. + - **Parameter 'OpenTelemetry Auth Header'** (Optional) - Configure additional authentication headers if required by your OpenTelemetry endpoint. The value should be in the format `HeaderName: HeaderValue` (e.g., `x-api-key: your-key` or `Authorization: Bearer your-token`). To securely store credentials, you can use Named Credential merge fields like `{!$Credential.YourNamedCredentialName}` which Salesforce will automatically resolve at runtime. Leave this parameter blank if no authentication is required or if authentication is already handled by the Named Credential configured for the endpoint URL. 4. If not using Named Credentials, add a Remote Site Setting for your OpenTelemetry endpoint: - Go to Setup --> Remote Site Settings --> New Remote Site @@ -93,35 +93,205 @@ The OpenTelemetry integration should now be setup & working - any new logs that #### Setting up Named Credentials -_Note: these instructions are for setting up the improved Named Credentials, as legacy credentials are deprecated as of Winter '23. For more info, see [Salesforce's documentation](https://help.salesforce.com/s/articleView?id=sf.named_credentials_about.htm&type=5)._ +Named Credentials provide a secure way to store authentication details and endpoint URLs in Salesforce. They offer several advantages: + +- **Enhanced Security**: Credentials are encrypted and never exposed in code or logs +- **No Remote Site Settings Required**: Named Credentials automatically bypass the need for Remote Site Settings +- **Centralized Management**: Update endpoint URLs and credentials in one place +- **Certificate Support**: Can handle mutual TLS authentication and custom certificates +- **Audit Trail**: Changes to credentials are tracked in Salesforce's setup audit trail + +##### Setup Steps + +_Note: these instructions are for the improved Named Credentials (recommended). Legacy Named Credentials are deprecated as of Winter '23. For more info, see [Salesforce's documentation](https://help.salesforce.com/s/articleView?id=sf.named_credentials_about.htm&type=5)._ + +**Step 1: Create an External Credential** + +An External Credential defines how Salesforce should authenticate with the OTLP endpoint. + +1. Go to **Setup** → **Named Credentials** → Click **New** under the **External Credentials** tab +2. Configure the External Credential: + - **Label**: `OpenTelemetry Endpoint` (or a descriptive name) + - **Name**: `OpenTelemetryEndpoint` (no spaces - this is the API name) + - **Authentication Protocol**: Select based on your OTLP endpoint's requirements: + - **No Authentication**: If your endpoint is internal or doesn't require auth + - **Password Authentication**: For API keys or basic authentication + - **JWT**: For JWT-based authentication + - **OAuth 2.0**: For OAuth flows + - **Custom**: For other authentication methods +3. Click **Save** + +**Step 2: Create a Principal for the External Credential** + +A Principal stores the actual credentials that will be used when calling the OTLP endpoint. + +1. On the External Credential detail page, scroll to the **Principals** section +2. Click **New** +3. Configure the Principal: + - **Parameter Name**: `Default` (or another descriptive name) + - **Identity Type**: `Named Principal` (credentials are shared across all users) + - **Authentication Parameters**: Enter based on the protocol selected in Step 1: + - For **Password Authentication** with API key: + - Sequence: 1 + - Parameter Name: Custom (e.g., `x-api-key` or `Authorization`) + - Value: Your API key or token + - For **Basic Auth**: + - Username: Your username + - Password: Your password + - For **JWT** or **OAuth**: Configure according to your provider's requirements +4. Click **Save** + +**Step 3: Create a Named Credential** + +The Named Credential combines the External Credential with the endpoint URL. + +1. Go to **Setup** → **Named Credentials** → Click **New** under the **Named Credentials** tab +2. Configure the Named Credential: + - **Label**: `OpenTelemetry Collector` (descriptive label) + - **Name**: `OpenTelemetryCollector` (no spaces - this is what you'll use in the `callout:` URL) + - **URL**: Your complete OTLP endpoint URL (e.g., `https://otel-collector.example.com/v1/logs`) + - **External Credential**: Select the External Credential you created in Step 1 + - **Callout Options** (check these): + - ✅ **Allow Formulas in HTTP Header**: Enables dynamic header values + - ✅ **Generate Authorization Header**: If using authentication that requires it + - **Enabled for Callouts**: ✅ Checked (default) +3. Click **Save** + +**Step 4: Grant Access to the External Credential** + +The Automated Process user (which runs queueable jobs) needs access to use the External Credential. + +1. Go to **Setup** → **Permission Sets** → Click **New** + - Or use an existing permission set if preferred +2. Create/edit a permission set: + - **Label**: `OpenTelemetry Integration Access` (or similar) + - **API Name**: `OpenTelemetry_Integration_Access` +3. Click **Save**, then on the permission set detail page: + - Scroll to **External Credential Principal Access** + - Click **Edit** + - Select the External Credential Principal you created in Step 2 + - Click **Add** to move it to the **Enabled External Credential Principal Access** section + - Click **Save** +4. Assign the permission set: + - Click **Manage Assignments** → **Add Assignment** + - Search for **Automated Process** user + - Select it and click **Assign** + - Click **Done** + +**Step 5: Configure the Logger Parameter** + +Finally, update the Logger Parameter to use your Named Credential: + +1. Go to **Setup** → **Custom Metadata Types** → **Logger Parameter** → **Manage Records** +2. Click on **OpenTelemetry Endpoint** +3. Set **Value** to: `callout:OpenTelemetryCollector` (using the **Name** field from Step 3) +4. Click **Save** + +##### Named Credential Configuration Examples + +**Example 1: API Key Authentication** + +If your OpenTelemetry endpoint requires an API key, you can store the credential value in a format that includes both the header name and value. The plugin will parse and apply it correctly. -1. **Create a new External Credential.** This will define how Salesforce should authenticate with the OTLP endpoint. +``` +External Credential: + Name: OpenTelemetryEndpoint + Auth Protocol: Password Authentication + +Principal: + Parameter Name: Custom (store the full header string) + Value: x-api-key: sk_live_1234567890abcdef + +Named Credential: + Name: OpenTelemetryCollector + URL: https://otel.example.com/v1/logs + +Logger Parameter Value (Endpoint): + callout:OpenTelemetryCollector + +Logger Parameter Value (Auth Header): + x-api-key: {!$Credential.OpenTelemetryEndpoint} + (Salesforce will replace the merge field with the actual credential value at runtime) +``` + +**Example 2: Bearer Token Authentication** - - Go to the Named Credentials page in setup, click `New` under the External Credentials tab. - - Enter a name (for example, `OpenTelemetry Endpoint`) - - Select the appropriate Authentication Protocol: - - For API key authentication: Select `Password Authentication` and configure accordingly - - For bearer token: Select `JWT` or `Password Authentication` - - For no authentication: Select `No Authentication` +For Bearer token authentication, store the complete Authorization header value in the Named Credential. + +``` +External Credential: + Name: OpenTelemetryAuth + Auth Protocol: Password Authentication + +Principal: + Parameter Name: Custom + Value: Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + +Named Credential: + Name: OpenTelemetryCollector + URL: https://api.observability.com/v1/logs + +Logger Parameter Value (Endpoint): + callout:OpenTelemetryCollector + +Logger Parameter Value (Auth Header): + Authorization: {!$Credential.OpenTelemetryAuth} + (Or embed the token directly: Authorization: Bearer {!$Credential.OpenTelemetryAuth}) +``` -2. **Create a Principle for the External Credential.** This will define the credentials that should be used when calling out to the OTLP endpoint. +**Example 3: No Authentication (Internal Endpoint)** + +For internal endpoints that don't require authentication, simply leave the Auth Header parameter blank. + +``` +External Credential: + Name: OpenTelemetryEndpoint + Auth Protocol: No Authentication + +Named Credential: + Name: OpenTelemetryCollector + URL: https://internal-otel-collector.local/v1/logs + +Logger Parameter Value (Endpoint): + callout:OpenTelemetryCollector + +Logger Parameter Value (Auth Header): + Leave blank (no authentication required) +``` + +**Example 4: Direct Value (Not using Named Credentials for Auth Header)** + +You can also set the auth header directly without using Named Credentials, though this is less secure: + +``` +Logger Parameter Value (Endpoint): + https://otel.example.com/v1/logs + (Don't forget to configure Remote Site Settings!) + +Logger Parameter Value (Auth Header): + x-api-key: hardcoded-key-here + (Not recommended for production - use Named Credentials instead) +``` - - In the Principals section of the External Credential you just created, click `New`. - - Enter a parameter name (for example: `Default` or `Standard`). - - If using authentication, enter the required credentials (API key, username/password, etc.) +##### Troubleshooting Named Credentials -3. **Create a new Named Credential.** This is where the OTLP endpoint URL will be stored. +**Issue: "Unauthorized endpoint" error** +- **Solution**: Named Credentials automatically bypass Remote Site Settings, but verify the Named Credential is properly configured and the URL is correct - - Go back to the main Named Credentials page and click `New` in the Named Credentials tab. - - Enter a name for the Named Credential (for example: `OpenTelemetry_Collector`). - - Paste the OTLP endpoint URL into the URL field (e.g., `https://otel-collector.example.com/v1/logs`). - - In the External Credential dropdown, select the one you created in step 1. +**Issue: "Unauthorized" or 401 errors** +- **Solution**: + - Verify the Principal credentials are correct + - Check that the authentication header format matches your endpoint's requirements + - Ensure "Generate Authorization Header" is checked if using Bearer token authentication -4. **Grant the Platform Integration User access to the External Credential.** This will allow the Platform Integration user (the running user for queueable jobs) to make callouts to the OTLP endpoint. +**Issue: "You do not have the level of access necessary to perform the operation you requested"** +- **Solution**: Verify the Automated Process user has been assigned the permission set with External Credential Principal Access - - Create a new permission set or open an existing one - - Go to the External Credential Principal Access section of the permission set and grant access to the External Credential you created in step 1. - - Assign the permission set to the user that runs async jobs (typically the Automated Process user or the user whose context the queueable runs in). +**Issue: Logs show callout error "Unable to tunnel through proxy"** +- **Solution**: This typically indicates a network connectivity issue. Verify: + - The endpoint URL is accessible from Salesforce + - Any firewall rules allow traffic from Salesforce IP ranges + - The SSL certificate on the endpoint is valid --- diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls index d853f9dd5..9fbb71e3c 100644 --- a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls @@ -210,11 +210,14 @@ public without sharing class OpenTelemetryLoggerPlugin implements LoggerPlugin.T request.setHeader('Content-Type', 'application/json'); // Add authentication header if configured + // Expected format from Named Credential: "HeaderName: HeaderValue" + // e.g., "Authorization: Bearer token123" or "x-api-key: key123" if (String.isNotBlank(AUTH_HEADER)) { - // Expected format: "Authorization: Bearer " or "x-api-key: " List headerParts = AUTH_HEADER.split(':', 2); if (headerParts.size() == 2) { - request.setHeader(headerParts[0].trim(), headerParts[1].trim()); + String headerName = headerParts[0].trim(); + String headerValue = headerParts[1].trim(); + request.setHeader(headerName, headerValue); } } From df1812ec6917667d5df98f98fd5036e91b8b2e31 Mon Sep 17 00:00:00 2001 From: howardyoo Date: Wed, 26 Nov 2025 21:44:17 -0600 Subject: [PATCH 08/10] support for named credentials in auth header --- .../OpenTelemetryLoggerPlugin_Tests.cls | 26 +++++++++++++++++-- ...ameter.OpenTelemetryAuthHeader.md-meta.xml | 2 +- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls index c6a41bc65..afa9b27b9 100644 --- a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls @@ -188,7 +188,9 @@ private class OpenTelemetryLoggerPlugin_Tests { @IsTest static void it_should_include_auth_header_when_configured() { System.LoggingLevel logEntryLoggingLevel = System.LoggingLevel.ERROR; - String authHeader = 'Authorization: Bearer test-token-123'; + // Simulate the value that would be retrieved from a Named Credential + // Format: "HeaderName: HeaderValue" + String authHeader = 'x-api-key: test-api-key-123'; LoggerPlugin__mdt pluginConfiguration = mockConfigurations(logEntryLoggingLevel, authHeader); List logs = insertLogAndLogEntry(logEntryLoggingLevel, true); LoggerTriggerableContext context = mockContext(System.TriggerOperation.AFTER_UPDATE, logs); @@ -201,7 +203,27 @@ private class OpenTelemetryLoggerPlugin_Tests { System.Test.stopTest(); System.Assert.isNotNull(calloutMock.request, 'HTTP request should have been made'); - System.Assert.areEqual('Bearer test-token-123', calloutMock.request.getHeader('Authorization'), 'Authorization header should be set'); + System.Assert.areEqual('test-api-key-123', calloutMock.request.getHeader('x-api-key'), 'x-api-key header should be set correctly'); + } + + @IsTest + static void it_should_parse_bearer_token_auth_header_when_configured() { + System.LoggingLevel logEntryLoggingLevel = System.LoggingLevel.ERROR; + // Simulate a Bearer token from a Named Credential + String authHeader = 'Authorization: Bearer test-token-xyz'; + LoggerPlugin__mdt pluginConfiguration = mockConfigurations(logEntryLoggingLevel, authHeader); + List logs = insertLogAndLogEntry(logEntryLoggingLevel, true); + LoggerTriggerableContext context = mockContext(System.TriggerOperation.AFTER_UPDATE, logs); + LoggerMockDataCreator.MockHttpCallout calloutMock = LoggerMockDataCreator.createHttpCallout().setStatusCode(200); + System.Test.setMock(System.HttpCalloutMock.class, calloutMock); + + System.Test.startTest(); + OpenTelemetryLoggerPlugin plugin = new OpenTelemetryLoggerPlugin(); + plugin.execute(pluginConfiguration, context); + System.Test.stopTest(); + + System.Assert.isNotNull(calloutMock.request, 'HTTP request should have been made'); + System.Assert.areEqual('Bearer test-token-xyz', calloutMock.request.getHeader('Authorization'), 'Authorization header should be set correctly with Bearer token'); } @IsTest diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryAuthHeader.md-meta.xml b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryAuthHeader.md-meta.xml index c753ee972..300742730 100644 --- a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryAuthHeader.md-meta.xml +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryAuthHeader.md-meta.xml @@ -4,7 +4,7 @@ false Description__c - Optional authentication header for OpenTelemetry endpoint. Format: 'HeaderName: HeaderValue' (e.g., 'Authorization: Bearer your-token' or 'x-api-key: your-key'). Leave blank if using Named Credentials or if no authentication is required + Optional authentication header for OpenTelemetry endpoint. Format: 'HeaderName: HeaderValue' (e.g., 'Authorization: Bearer token' or 'x-api-key: key123'). This value can be retrieved from a Named Credential by using merge fields like {!$Credential.NamedCredentialName}. Leave blank if no authentication is required Value__c From da4a4dda1cfcfaf80b090a3cf6f97eeabb72d8e1 Mon Sep 17 00:00:00 2001 From: howardyoo Date: Wed, 26 Nov 2025 23:59:43 -0600 Subject: [PATCH 09/10] Removed Auth Header as configuration, for better security. --- .../plugins/opentelemetry/INSTALLATION.md | 84 +++++++++++++------ nebula-logger/plugins/opentelemetry/README.md | 78 ++++++++--------- .../OpenTelemetryLoggerPlugin_Tests.cls | 48 ----------- 3 files changed, 93 insertions(+), 117 deletions(-) diff --git a/nebula-logger/plugins/opentelemetry/INSTALLATION.md b/nebula-logger/plugins/opentelemetry/INSTALLATION.md index bfbadf90e..944ab83ab 100644 --- a/nebula-logger/plugins/opentelemetry/INSTALLATION.md +++ b/nebula-logger/plugins/opentelemetry/INSTALLATION.md @@ -39,12 +39,51 @@ Named Credentials provide better security and don't require Remote Site Settings Quick setup: 1. **Setup** → **Named Credentials** → **External Credentials** → **New** 2. Create External Credential with appropriate authentication protocol -3. Add a Principal with your credentials (API key, token, etc.) -4. **Named Credentials** → **New** → Link to External Credential and add endpoint URL -5. Create/edit a permission set to grant External Credential Principal Access -6. Assign permission set to **Automated Process** user +3. Add a principal with your credentials (API key, token, etc.) +4. Add header key value pair in case you are setting the key via header +5. **Named Credentials** → **New** → Link to External Credential and add endpoint URL +6. Select the External Credentials which you created in step 1~3 +7. Select callout options (e.g. Generating Auth Header, Allow Formulas in HTTP Header, etc.) +8. **IMPORTANT**: Grant the Automated Process user access to the External Credential (see detailed steps below) + +### 3. Grant Access to External Credential (Required for Named Credentials) + +If you're using Named Credentials, you MUST grant the Automated Process user access to the External Credential. Without this, you'll get an error: "We couldn't access the credential(s)." + +**Steps to Grant Access:** + +1. Go to **Setup** → **Permission Sets** → Click **New** +2. Create a new permission set: + - **Label**: `OpenTelemetry External Credential Access` (or any name you prefer) + - **API Name**: `OpenTelemetry_External_Credential_Access` + - **License**: Select `--None--` +3. Click **Save** + +4. On the permission set detail page: + - Scroll down to **External Credential Principal Access** section + - Click **Edit** + - Find your External Credential Principal (e.g., "honeycomb - Default" or whatever you named it) + - Select it and click **Add** to move it to the **Enabled External Credential Principal Access** section + - Click **Save** + +5. Assign the permission set to the Automated Process user: + - Still on the permission set detail page, click **Manage Assignments** + - Click **Add Assignment** + - In the search box, type "Automated Process" + - Select the **Automated Process** user (it should show "System User" as the User Type) + - Click **Assign** + - Click **Done** + +6. Verify the assignment: + - Go back to the permission set detail page + - You should see "Automated Process" in the list of assigned users + +**Troubleshooting:** +- If you can't find "Automated Process" user, make sure you're searching for it (not filtering by profile) +- If the error persists, verify the External Credential name matches exactly what you configured in the Named Credential +- Check that the External Credential Principal is not expired or disabled -### 3. Configure Logger Parameters +### 4. Configure Logger Parameters 1. Go to **Setup** → **Custom Metadata Types** → **Logger Parameter** → **Manage Records** @@ -65,23 +104,14 @@ Quick setup: - Set **Value**: Version identifier (e.g., `1.0.0`, `2023-Q4`) - Default: `1.0.0` -6. (Optional) Configure **OpenTelemetry Auth Header**: - - **Format**: `HeaderName: HeaderValue` (e.g., `x-api-key: your-key` or `Authorization: Bearer your-token`) - - **Using Named Credentials**: To securely store credentials, use merge fields like `{!$Credential.YourNamedCredentialName}` - - Example: `x-api-key: {!$Credential.OpenTelemetryAuth}` - - Salesforce will automatically resolve the merge field at runtime - - **Direct value** (less secure): Enter the auth header directly (e.g., `x-api-key: hardcoded-key`) - - **If no authentication is required**: Leave this blank - - Click **Save** - -### 4. Enable the Plugin +### 5. Enable the Plugin 1. Go to **Setup** → **Custom Metadata Types** → **Logger Plugin** → **Manage Records** 2. Click on **OpenTelemetry integration** 3. Ensure **Is Enabled** is checked 4. Click **Save** -### 5. Assign Permissions (Optional) +### 6. Assign Permissions (Optional) If you want users to be able to view/edit the OpenTelemetry fields on Log records: @@ -89,7 +119,7 @@ If you want users to be able to view/edit the OpenTelemetry fields on Log record 2. Find **Nebula Logger: OpenTelemetry Plugin Admin** 3. Assign it to the appropriate users or integrate it into your permission set groups -### 6. Test the Integration +### 7. Test the Integration Create a test log to verify the integration: @@ -135,18 +165,24 @@ Then verify: ### Common Issues +- **"We couldn't access the credential(s)" error**: + - **MOST COMMON ISSUE**: The Automated Process user doesn't have access to the External Credential Principal + - **Solution**: Follow the steps in Section 3 above to grant access via a permission set + - Verify the External Credential name matches exactly (case-sensitive) + - Ensure the permission set assignment to Automated Process user was successful + - **401 Unauthorized**: - - Check your authentication configuration - - Verify the Auth Header parameter is in the correct format: `HeaderName: HeaderValue` - - If using Named Credential merge fields (e.g., `{!$Credential.Name}`), ensure the credential exists and is accessible - - Verify the Named Credential's External Credential Principal has correct credentials - - Verify "Generate Authorization Header" is checked on the Named Credential if using Bearer token authentication + - Check your Named Credential authentication configuration + - Verify the External Credential Principal has correct credentials + - Ensure "Generate Authorization Header" is checked on the Named Credential if using Bearer token authentication + - **404 Not Found**: Verify the endpoint URL includes the full path (e.g., `/v1/logs`) + - **No callout**: - If using direct URL: Verify Remote Site Settings are configured for your endpoint domain - If using Named Credentials: Verify the Automated Process user has access to the External Credential Principal (via permission set) -- **"You do not have the level of access necessary"**: The Automated Process user needs the permission set with External Credential Principal Access assigned -- **Auth header not working**: Ensure the parameter value is in the exact format `HeaderName: HeaderValue` with a colon separator + +- **"You do not have the level of access necessary"**: The Automated Process user needs the permission set with External Credential Principal Access assigned (see Section 3) --- diff --git a/nebula-logger/plugins/opentelemetry/README.md b/nebula-logger/plugins/opentelemetry/README.md index 655951682..3cd1b0c33 100644 --- a/nebula-logger/plugins/opentelemetry/README.md +++ b/nebula-logger/plugins/opentelemetry/README.md @@ -80,8 +80,6 @@ The endpoint URL should be the OTLP HTTP endpoint, typically ending in `/v1/logs - **Parameter 'OpenTelemetry Service Name'** (Optional) - Set the service name to identify your Salesforce org in the observability platform. Defaults to 'Salesforce' if not specified. - **Parameter 'OpenTelemetry Service Version'** (Optional) - Set the service version. Defaults to '1.0.0' if not specified. - - - **Parameter 'OpenTelemetry Auth Header'** (Optional) - Configure additional authentication headers if required by your OpenTelemetry endpoint. The value should be in the format `HeaderName: HeaderValue` (e.g., `x-api-key: your-key` or `Authorization: Bearer your-token`). To securely store credentials, you can use Named Credential merge fields like `{!$Credential.YourNamedCredentialName}` which Salesforce will automatically resolve at runtime. Leave this parameter blank if no authentication is required or if authentication is already handled by the Named Credential configured for the endpoint URL. 4. If not using Named Credentials, add a Remote Site Setting for your OpenTelemetry endpoint: - Go to Setup --> Remote Site Settings --> New Remote Site @@ -159,25 +157,33 @@ The Named Credential combines the External Credential with the endpoint URL. **Step 4: Grant Access to the External Credential** -The Automated Process user (which runs queueable jobs) needs access to use the External Credential. +**CRITICAL STEP**: The Automated Process user (which runs queueable jobs) needs access to use the External Credential. **Without this step, the plugin will fail with an error: "We couldn't access the credential(s)."** 1. Go to **Setup** → **Permission Sets** → Click **New** - Or use an existing permission set if preferred 2. Create/edit a permission set: - **Label**: `OpenTelemetry Integration Access` (or similar) - **API Name**: `OpenTelemetry_Integration_Access` + - **License**: Select `--None--` (this allows it to be assigned to system users like Automated Process) 3. Click **Save**, then on the permission set detail page: - Scroll to **External Credential Principal Access** - Click **Edit** - - Select the External Credential Principal you created in Step 2 - - Click **Add** to move it to the **Enabled External Credential Principal Access** section + - Find the External Credential Principal you created in Step 2 + - It will show as "ExternalCredentialName - PrincipalName" (e.g., "honeycomb - Default") + - Select it and click **Add** to move it to the **Enabled External Credential Principal Access** section - Click **Save** 4. Assign the permission set: - Click **Manage Assignments** → **Add Assignment** - - Search for **Automated Process** user + - **IMPORTANT**: Search for **Automated Process** user + - Type "Automated Process" in the search box + - You should see a user with User Type = "System User" - Select it and click **Assign** - Click **Done** +**Verification:** +- Go back to the permission set and confirm "Automated Process" appears in the list of assignments +- The Automated Process user should now have access to call your OpenTelemetry endpoint using the Named Credential + **Step 5: Configure the Logger Parameter** Finally, update the Logger Parameter to use your Named Credential: @@ -191,7 +197,7 @@ Finally, update the Logger Parameter to use your Named Credential: **Example 1: API Key Authentication** -If your OpenTelemetry endpoint requires an API key, you can store the credential value in a format that includes both the header name and value. The plugin will parse and apply it correctly. +Configure Named Credentials with the External Credential containing your API key authentication details. ``` External Credential: @@ -199,8 +205,8 @@ External Credential: Auth Protocol: Password Authentication Principal: - Parameter Name: Custom (store the full header string) - Value: x-api-key: sk_live_1234567890abcdef + Header: x-api-key + Value: sk_live_1234567890abcdef Named Credential: Name: OpenTelemetryCollector @@ -208,40 +214,33 @@ Named Credential: Logger Parameter Value (Endpoint): callout:OpenTelemetryCollector - -Logger Parameter Value (Auth Header): - x-api-key: {!$Credential.OpenTelemetryEndpoint} - (Salesforce will replace the merge field with the actual credential value at runtime) ``` **Example 2: Bearer Token Authentication** -For Bearer token authentication, store the complete Authorization header value in the Named Credential. +For Bearer token authentication, configure the External Credential with authentication details. ``` External Credential: - Name: OpenTelemetryAuth + Name: OpenTelemetryEndpoint Auth Protocol: Password Authentication Principal: - Parameter Name: Custom - Value: Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + Header: Authorization + Value: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... Named Credential: Name: OpenTelemetryCollector URL: https://api.observability.com/v1/logs + Generate Authorization Header: ✅ Logger Parameter Value (Endpoint): callout:OpenTelemetryCollector - -Logger Parameter Value (Auth Header): - Authorization: {!$Credential.OpenTelemetryAuth} - (Or embed the token directly: Authorization: Bearer {!$Credential.OpenTelemetryAuth}) ``` **Example 3: No Authentication (Internal Endpoint)** -For internal endpoints that don't require authentication, simply leave the Auth Header parameter blank. +For internal endpoints that don't require authentication, use No Authentication protocol. ``` External Credential: @@ -254,27 +253,18 @@ Named Credential: Logger Parameter Value (Endpoint): callout:OpenTelemetryCollector - -Logger Parameter Value (Auth Header): - Leave blank (no authentication required) -``` - -**Example 4: Direct Value (Not using Named Credentials for Auth Header)** - -You can also set the auth header directly without using Named Credentials, though this is less secure: - -``` -Logger Parameter Value (Endpoint): - https://otel.example.com/v1/logs - (Don't forget to configure Remote Site Settings!) - -Logger Parameter Value (Auth Header): - x-api-key: hardcoded-key-here - (Not recommended for production - use Named Credentials instead) ``` ##### Troubleshooting Named Credentials +**Issue: "We couldn't access the credential(s). You might not have the required permissions, or the external credential might not exist"** +- **Solution**: This is the most common issue. The Automated Process user needs access to the External Credential Principal. + - Go to **Setup** → **Permission Sets** + - Find the permission set with External Credential Principal Access + - Verify the External Credential Principal is enabled in the permission set + - Verify the **Automated Process** user is assigned to this permission set + - The External Credential name must match exactly (case-sensitive) + **Issue: "Unauthorized endpoint" error** - **Solution**: Named Credentials automatically bypass Remote Site Settings, but verify the Named Credential is properly configured and the URL is correct @@ -284,9 +274,6 @@ Logger Parameter Value (Auth Header): - Check that the authentication header format matches your endpoint's requirements - Ensure "Generate Authorization Header" is checked if using Bearer token authentication -**Issue: "You do not have the level of access necessary to perform the operation you requested"** -- **Solution**: Verify the Automated Process user has been assigned the permission set with External Credential Principal Access - **Issue: Logs show callout error "Unable to tunnel through proxy"** - **Solution**: This typically indicates a network connectivity issue. Verify: - The endpoint URL is accessible from Salesforce @@ -453,8 +440,9 @@ Once your logs are in an OpenTelemetry-compatible backend, you can query them us If you're seeing HTTP errors: -1. **401 Unauthorized**: Check your authentication configuration - - Verify the `OpenTelemetry Auth Header` parameter or Named Credential credentials +1. **401 Unauthorized**: Check your Named Credential authentication configuration + - Verify the External Credential Principal credentials are correct + - Ensure "Generate Authorization Header" is checked if using Bearer token authentication 2. **404 Not Found**: Verify the endpoint URL is correct - Ensure it includes the full path (e.g., `/v1/logs`) @@ -483,7 +471,7 @@ The plugin is designed to handle governor limits efficiently: ## Security Best Practices -1. **Use Named Credentials**: Instead of storing endpoint URLs and API keys directly in custom metadata, use Named Credentials for better security +1. **Use Named Credentials**: Always use Named Credentials to securely store endpoint URLs and authentication credentials 2. **Restrict Field Access**: Use the provided permission set to control who can view/edit the OpenTelemetry fields 3. **Monitor Callouts**: Regularly review callout logs to detect any unauthorized access attempts 4. **Secure Your Endpoint**: Ensure your OTLP endpoint uses HTTPS and proper authentication diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls index afa9b27b9..27ece4b93 100644 --- a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls @@ -185,47 +185,6 @@ private class OpenTelemetryLoggerPlugin_Tests { validateOpenTelemetryPayload(calloutMock, logs.get(0), 'FINEST'); } - @IsTest - static void it_should_include_auth_header_when_configured() { - System.LoggingLevel logEntryLoggingLevel = System.LoggingLevel.ERROR; - // Simulate the value that would be retrieved from a Named Credential - // Format: "HeaderName: HeaderValue" - String authHeader = 'x-api-key: test-api-key-123'; - LoggerPlugin__mdt pluginConfiguration = mockConfigurations(logEntryLoggingLevel, authHeader); - List logs = insertLogAndLogEntry(logEntryLoggingLevel, true); - LoggerTriggerableContext context = mockContext(System.TriggerOperation.AFTER_UPDATE, logs); - LoggerMockDataCreator.MockHttpCallout calloutMock = LoggerMockDataCreator.createHttpCallout().setStatusCode(200); - System.Test.setMock(System.HttpCalloutMock.class, calloutMock); - - System.Test.startTest(); - OpenTelemetryLoggerPlugin plugin = new OpenTelemetryLoggerPlugin(); - plugin.execute(pluginConfiguration, context); - System.Test.stopTest(); - - System.Assert.isNotNull(calloutMock.request, 'HTTP request should have been made'); - System.Assert.areEqual('test-api-key-123', calloutMock.request.getHeader('x-api-key'), 'x-api-key header should be set correctly'); - } - - @IsTest - static void it_should_parse_bearer_token_auth_header_when_configured() { - System.LoggingLevel logEntryLoggingLevel = System.LoggingLevel.ERROR; - // Simulate a Bearer token from a Named Credential - String authHeader = 'Authorization: Bearer test-token-xyz'; - LoggerPlugin__mdt pluginConfiguration = mockConfigurations(logEntryLoggingLevel, authHeader); - List logs = insertLogAndLogEntry(logEntryLoggingLevel, true); - LoggerTriggerableContext context = mockContext(System.TriggerOperation.AFTER_UPDATE, logs); - LoggerMockDataCreator.MockHttpCallout calloutMock = LoggerMockDataCreator.createHttpCallout().setStatusCode(200); - System.Test.setMock(System.HttpCalloutMock.class, calloutMock); - - System.Test.startTest(); - OpenTelemetryLoggerPlugin plugin = new OpenTelemetryLoggerPlugin(); - plugin.execute(pluginConfiguration, context); - System.Test.stopTest(); - - System.Assert.isNotNull(calloutMock.request, 'HTTP request should have been made'); - System.Assert.areEqual('Bearer test-token-xyz', calloutMock.request.getHeader('Authorization'), 'Authorization header should be set correctly with Bearer token'); - } - @IsTest static void it_should_handle_exception_data_in_log_entry() { System.LoggingLevel logEntryLoggingLevel = System.LoggingLevel.ERROR; @@ -279,10 +238,6 @@ private class OpenTelemetryLoggerPlugin_Tests { } private static LoggerPlugin__mdt mockConfigurations(System.LoggingLevel notificationLoggingLevel) { - return mockConfigurations(notificationLoggingLevel, null); - } - - private static LoggerPlugin__mdt mockConfigurations(System.LoggingLevel notificationLoggingLevel, String authHeader) { String mockEndpoint = 'https://fake.otel.example.com/v1/logs'; LoggerTestConfigurator.setMock(new LoggerParameter__mdt(DeveloperName = 'OpenTelemetryEndpoint', Value__c = mockEndpoint)); LoggerTestConfigurator.setMock(new LoggerParameter__mdt(DeveloperName = 'OpenTelemetryServiceName', Value__c = 'Salesforce')); @@ -290,9 +245,6 @@ private class OpenTelemetryLoggerPlugin_Tests { LoggerTestConfigurator.setMock( new LoggerParameter__mdt(DeveloperName = 'OpenTelemetryNotificationLoggingLevel', Value__c = notificationLoggingLevel.name()) ); - if (String.isNotBlank(authHeader)) { - LoggerTestConfigurator.setMock(new LoggerParameter__mdt(DeveloperName = 'OpenTelemetryAuthHeader', Value__c = authHeader)); - } System.Assert.areEqual(mockEndpoint, LoggerParameter.getString('OpenTelemetryEndpoint', null)); System.Assert.areEqual(mockEndpoint, OpenTelemetryLoggerPlugin.ENDPOINT); System.Assert.areEqual(notificationLoggingLevel.name(), LoggerParameter.getString('OpenTelemetryNotificationLoggingLevel', null)); From 70f98567bb11f3974a192c9dd0146b0800283809 Mon Sep 17 00:00:00 2001 From: howardyoo Date: Thu, 27 Nov 2025 00:00:10 -0600 Subject: [PATCH 10/10] removed Auth header as configuration --- .../classes/OpenTelemetryLoggerPlugin.cls | 14 -------------- ...erParameter.OpenTelemetryAuthHeader.md-meta.xml | 14 -------------- 2 files changed, 28 deletions(-) delete mode 100644 nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryAuthHeader.md-meta.xml diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls index 9fbb71e3c..d2f3b0289 100644 --- a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls @@ -21,8 +21,6 @@ public without sharing class OpenTelemetryLoggerPlugin implements LoggerPlugin.T ); @TestVisible private static final String OTLP_VERSION = '1.0.0'; - @TestVisible - private static final String AUTH_HEADER = LoggerParameter.getString('OpenTelemetryAuthHeader', null); private List logs; @@ -208,18 +206,6 @@ public without sharing class OpenTelemetryLoggerPlugin implements LoggerPlugin.T request.setEndpoint(ENDPOINT); request.setMethod('POST'); request.setHeader('Content-Type', 'application/json'); - - // Add authentication header if configured - // Expected format from Named Credential: "HeaderName: HeaderValue" - // e.g., "Authorization: Bearer token123" or "x-api-key: key123" - if (String.isNotBlank(AUTH_HEADER)) { - List headerParts = AUTH_HEADER.split(':', 2); - if (headerParts.size() == 2) { - String headerName = headerParts[0].trim(); - String headerValue = headerParts[1].trim(); - request.setHeader(headerName, headerValue); - } - } // Build the OTLP JSON payload String payload = this.buildOtlpPayload(log); diff --git a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryAuthHeader.md-meta.xml b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryAuthHeader.md-meta.xml deleted file mode 100644 index 300742730..000000000 --- a/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/customMetadata/LoggerParameter.OpenTelemetryAuthHeader.md-meta.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - false - - Description__c - Optional authentication header for OpenTelemetry endpoint. Format: 'HeaderName: HeaderValue' (e.g., 'Authorization: Bearer token' or 'x-api-key: key123'). This value can be retrieved from a Named Credential by using merge fields like {!$Credential.NamedCredentialName}. Leave blank if no authentication is required - - - Value__c - - - -