diff --git a/nebula-logger/plugins/opentelemetry/INSTALLATION.md b/nebula-logger/plugins/opentelemetry/INSTALLATION.md new file mode 100644 index 000000000..944ab83ab --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/INSTALLATION.md @@ -0,0 +1,199 @@ +# 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 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** +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** + +**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. 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 + +### 4. Configure Logger Parameters + +1. Go to **Setup** → **Custom Metadata Types** → **Logger Parameter** → **Manage Records** + +2. Click on **OpenTelemetry Endpoint**: + - **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**: + - 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` + +### 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** + +### 6. 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 + +### 7. 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 + +- **"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 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 (see Section 3) + +--- + +## 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..3cd1b0c33 --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/README.md @@ -0,0 +1,500 @@ +# 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: + - **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. + + - **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. + +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 + +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** + +**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** + - 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** + - **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: + +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** + +Configure Named Credentials with the External Credential containing your API key authentication details. + +``` +External Credential: + Name: OpenTelemetryEndpoint + Auth Protocol: Password Authentication + +Principal: + Header: x-api-key + Value: sk_live_1234567890abcdef + +Named Credential: + Name: OpenTelemetryCollector + URL: https://otel.example.com/v1/logs + +Logger Parameter Value (Endpoint): + callout:OpenTelemetryCollector +``` + +**Example 2: Bearer Token Authentication** + +For Bearer token authentication, configure the External Credential with authentication details. + +``` +External Credential: + Name: OpenTelemetryEndpoint + Auth Protocol: Password Authentication + +Principal: + 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 +``` + +**Example 3: No Authentication (Internal Endpoint)** + +For internal endpoints that don't require authentication, use No Authentication protocol. + +``` +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 +``` + +##### 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 + +**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 + +**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 + +--- + +## 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 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`) + +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**: 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 +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..d2f3b0289 --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls @@ -0,0 +1,486 @@ +//------------------------------------------------------------------------------------------------// +// 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, System.Finalizer, 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'; + + 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) { + // 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(); + + if (this.logs.isEmpty()) { + return; + } + + List sentLogs = new List(); + List unsentLogs = new List(); + DateTime exportTime = 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 + // 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 = exportTime; + 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)); + } + } + + /** + * @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() { + 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) { + // 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); + } + } + // 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) { + 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'); + + // 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 attributes; + } + + @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..fad4aeb3e --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin.cls-meta.xml @@ -0,0 +1,6 @@ + + + 64.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..27ece4b93 --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls @@ -0,0 +1,344 @@ +//------------------------------------------------------------------------------------------------// +// 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_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'); + } + + @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) { + 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()) + ); + 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..fad4aeb3e --- /dev/null +++ b/nebula-logger/plugins/opentelemetry/plugin/opentelemetry/classes/OpenTelemetryLoggerPlugin_Tests.cls-meta.xml @@ -0,0 +1,6 @@ + + + 64.0 + Active + + 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 + + +