RFC: Support for external observability providers - LoggingΒ #1500
Description
Is this related to an existing feature request or issue?
Powertools for AWS Lambda (Python) #2014, #1261, #646
Which Powertools for AWS Lambda (TypeScript) utility does this relate to?
Logger
Summary
Powertools for Python has implemented support for certain external observability providers out of the box for the Logger utility by extending the LogFormatter feature according to Powertools for AWS Lambda (Python) #2014. This allows customers using designated providers to use the Logger utility with either the default or a custom LogFormatter without having to design their own configurations to allow for integration with observability solutions other than CloudWatch.
In Powertools for TypeScript, the current LogFormatter lacks customizability on certain features, as described in issue #1261. By extending the capabilities of the LogFormatter to accommodate changes on all log features, we aim to better support third-party observability providers, starting with Datadog, and expanding to encompass the same list of providers as Powertools for Python.
Use case
The primary use case for this utility will be for customers who might want to extend the default LogFormatter to fully customize the logs emitted by Logger so that they can conform with their requirements or better work with supported AWS Lambda Ready Partners and AWS Partners observability providers.
Proposal
Terminology
- Standard attributes β keys provided to structured logging by the default log formatter
- Context-related attributes β added to standard attributes if developer uses injectLambdaContext() or calls the addContext() method for their logger
- Base attributes β standard attributes and context related attributes that are currently handled by the formatter
- Persistent attributes β defined by the developer and persists through all instances of logger output
- Extra Input attributes β provided for logger output on a singular instance
Current LogFormatter Experience
Powertools for Typescript currently provides a base abstract class LogFormatter
with the abstract method formatAttributes
that must be implemented to define the formatting of base attributes:
public abstract formatAttributes(
attributes: UnformattedAttributes
): LogAttributes;
Our default implementation for the Logger uses the PowertoolLogFormatter
class that builds on top of the LogFormatter
abstract class, maintaining its members and methods. The class provides the following implementation for the formatAttributes
method:
public formatAttributes(attributes: UnformattedAttributes): PowertoolLog {
return {
cold_start: attributes.lambdaContext?.coldStart,
function_arn: attributes.lambdaContext?.invokedFunctionArn,
function_memory_size: attributes.lambdaContext?.memoryLimitInMB,
function_name: attributes.lambdaContext?.functionName,
function_request_id: attributes.lambdaContext?.awsRequestId,
level: attributes.logLevel,
message: attributes.message,
sampling_rate: attributes.sampleRateValue,
service: attributes.serviceName,
timestamp: this.formatTimestamp(attributes.timestamp),
xray_trace_id: attributes.xRayTraceId,
};
}
This block of code provides default formatting for standard attributes and context-related ones if present. This formatted list of attributes is of type PowertoolLog
, which is an alias for a specific list of LogAttributes
.
If customers wish to customize the formatting to meet their own requirements or to work with third-party observability providers, they can define a similar class to alter the formatting on these standard and context-related attributes:
class MyCompanyLogFormatter extends LogFormatter {
public formatAttributes(attributes: UnformattedAttributes): MyCompanyLog {
return {
message: attributes.message,
service: attributes.serviceName,
environment: attributes.environment,
awsRegion: attributes.awsRegion,
correlationIds: {
awsRequestId: attributes.lambdaContext?.awsRequestId,
xRayTraceId: attributes.xRayTraceId,
},
lambdaFunction: {
name: attributes.lambdaContext?.functionName,
arn: attributes.lambdaContext?.invokedFunctionArn,
memoryLimitInMB: attributes.lambdaContext?.memoryLimitInMB,
version: attributes.lambdaContext?.functionVersion,
coldStart: attributes.lambdaContext?.coldStart,
},
logLevel: attributes.logLevel,
timestamp: this.formatTimestamp(attributes.timestamp),
logger: {
sampleRateValue: attributes.sampleRateValue,
},
};
}
}
export { MyCompanyLogFormatter };
Important to note here is the lack of functionality that is provided for altering persistent attributes or extra attributes. Instead, for persistent attributes added to the logger, it maintains the formatting that the user provided the attributes in. These are aggregated as another set of LogAttributes
.
Both sets of LogAttributes
(one for the standard attributes and context-related ones and one for the persistent attributes) are combined in the constructor for a LogItem
, which has a private member attributes that is a list of LogAttributes
.
public constructor(params: {
baseAttributes: LogAttributes;
persistentAttributes: LogAttributes;
}) {
this.addAttributes(params.baseAttributes);
this.addAttributes(params.persistentAttributes);
}
The benefit of the LogItem
is that it also possesses methods for adding, getting, deleting, and setting attributes. However, the addAttributes
method simply merges a new set of logAttributes
into attributes
of logItem
. This means that customers do not have the ability to reorganize their persistent attributes with the formatted list of base attributes, nor do they have the ability to reformat the persistent attributes, and the list is simply appended to the end of the attributes list.
For extra attributes, these attributes are converted into a set of logAttributes
and added to the attributes
list with the following code:
extraInput.forEach((item: Error | LogAttributes | string) => {
const attributes: LogAttributes =
item instanceof Error
? { error: item }
: typeof item === 'string'
? { extra: item }
: item;
logItem.addAttributes(attributes);
});
Similar to the persistent attributes, the extraInput
formatting here does not provide customers with the ability to reformat or reorganize the attributes with their existing list of attributes for the log. The new attributes are merely appended to the end of the attributes list, after the base attributes and persistent attributes.
Proposed PowertoolsLogFormatter Update
Firstly, to maintain naming consistency, PowertoolLogFormatter
and PowertoolLog
will be renamed to PowertoolsLogFormatter
and PowertoolsLog
. If this inconsistency is discovered in other parts of the Logger utility through the implementation of this proposal, the name will also be updated in those instances.
Instead of having persistent attributes and extra attributes merged into a LogItem
after the base attributes have been formatted, we propose merging the persistent attributes and extra attributes first, into a set of logAttributes
that can be passed into formatAttributes
instead:
var additionalLogAttributes: LogAttributes = this.getPersistentLogAttributes();
if (typeof input !== 'string') {
additionalLogAttributes = merge(additionalLogAttributes, input);
}
extraInput.forEach((item: Error | LogAttributes | string) => {
const attributes: LogAttributes =
item instanceof Error
? { error: item }
: typeof item === 'string'
? { extra: item }
: item;
additionalLogAttributes = merge(additionalLogAttributes, attributes);
});
This functionality will be maintained in the Logger file, where the LogItem
used to be created.
Then, to provide customers with access to formatting beyond the base attributes, we propose extending the definition of the base LogFormatter
class and its formatAttributes method to access the original unformatted baseAttributes
and the new additionalLogAttributes
set:
public abstract formatAttributes(
attributes: UnformattedAttributes,
additionalLogAttributes: LogAttributes
): LogItem;
In order to maintain a mask on the merging functionality provided in the LogItem
class, we will utilize addAttributes
, as defined in LogItem
, and formatAttributes
will be redefined to generate a LogItem
object rather than LogAttributes
.
Within the PowertoolsLogFormatter
, the formatAttributes
function will be redefined in accordance to the LogFormatter
update to take in all attributes, not just the base attributes:
public formatAttributes(attributes: UnformattedAttributes,
additionalLogAttributes: LogAttributes): LogItem {
const baseAttributes: PowertoolsLog = {
cold_start: attributes.lambdaContext?.coldStart,
function_arn: attributes.lambdaContext?.invokedFunctionArn,
function_memory_size: attributes.lambdaContext?.memoryLimitInMB,
function_name: attributes.lambdaContext?.functionName,
function_request_id: attributes.lambdaContext?.awsRequestId,
level: attributes.logLevel,
message: attributes.message,
sampling_rate: attributes.sampleRateValue,
service: attributes.serviceName,
timestamp: this.formatTimestamp(attributes.timestamp),
xray_trace_id: attributes.xRayTraceId,
};
const powertoolsLogItem = new LogItem({attributes: baseAttributes});
powertoolsLogItem.addAttributes(additionalLogAttributes);
return powertoolsLogItem;
}
We maintain the attributes of PowertoolsLog
but maintain the customizability of adding fields the same way current functionality does.
For a LogItem
object to be created from this, we will have to redefine the constructor of LogItem
to construct off a single attribute list. This will allow LogItem
to be more flexible in terms of its initial definition, while maintaining that other attributes can always be added or deleted later using its member functions.
public constructor(params: {
attributes: LogAttributes;
}) {
this.addAttributes(params.attributes);
}
Custom LogFormatter Usage
If the customer would like to use another observability provider, or define their own logger functions, customers can define their own Formatter class that the customer can implement based on our existing framework and pass in to the Logger class. The class would implement the formatAttributes
method similar to:
public formatAttributes(attributes: UnformattedAttributes,
additionalLogAttributes: LogAttributes): LogItem {
const attributeList: CustomLog = {
cold_start: attributes.lambdaContext?.coldStart,
function_arn: attributes.lambdaContext?.invokedFunctionArn,
function_memory_size: attributes.lambdaContext?.memoryLimitInMB,
function_name: attributes.lambdaContext?.functionName,
function_request_id: attributes.lambdaContext?.awsRequestId,
level: attributes.logLevel,
message: attributes.message,
sampling_rate: attributes.sampleRateValue,
service: attributes.serviceName,
timestamp: this.formatTimestamp(attributes.timestamp),
xray_trace_id: attributes.xRayTraceId,
custom_persistent_field = additionalLogAttributes.customPersistentField,
custom_error_field = additionalLogAttributes.myError,
custom_extra_field = additionalLogAttributes.customExtraField
}
const logItem = new LogItem({attributes: attributeList});
return logItem;
}
If customers wish to, they can define their own CustomLog
type to use as shown above that will pre-establish the fields of attributeList
, similar to the PowertoolsLog
type that the PowertoolsLogFormatter
uses for the base attributes.
Out of scope
Sending logs from Powertools to the customer's desired observability platform will not be in the scope of this project. The implementation should only support modifying the output of the Logger so that the customer can push them to their platform of choice.
Potential challenges
These proposed changes will likely result in a breaking change, which will require a major release and a migration plan/guide.
By reorganizing the PowertoolsLogFormatter, some functionality that is currently contained in LogItem may be removed or migrated to the Formatters. We will have to identify other dependencies of the LogItem class to ensure that other functionality is not lost.
We need to determine which platforms we want to support out-of-the-box.
Dependencies and Integrations
We will have to integrate with (and thus, have a dependency on) the platforms we decide to support out-of-the-box. For now, we will be focusing on Datadog, but this should be expanded to encompass the same providers supported by Powertools for Python, listed here.
Alternative solutions
To optimize the merging of LogAttributes
sets, we can consider migrating the removeEmptyKeys()
method from LogItem
to be used on small sets of attributes before they are merged together.
We have considered several different ways of handling passing the persistent attributes and extra attributes to the formatter:
- Combine persistent attributes and extra attributes into two separate sets of
LogAttributes
to pass intoformatAttributes
- As discussed by @dreamorosi in this comment, both types of attributes are treated equally anyways, and there isn't much reason to treat them separately.
- Pass persistent attributes and extra attributes in their raw form, without converting extra attributes into
LogAttributes
, so that customers can merge them into their log however they wish- The merging functionality ideally should remain masked from customers and remain in the
Logger
as much as possible, without encroaching on the more straightforward functionality for the formatter
- The merging functionality ideally should remain masked from customers and remain in the
Acknowledgment
- This feature request meets Powertools for AWS Lambda (TypeScript) Tenets
- Should this be considered in other Powertools for AWS Lambda languages? i.e. Python, Java, and .NET
Future readers
Please react with π and your use case to help us understand customer demand.
Metadata
Assignees
Labels
Type
Projects
Status
Shipped