diff --git a/.chloggen/ottl-timestamp.yaml b/.chloggen/ottl-timestamp.yaml new file mode 100644 index 000000000000..d7c0bf0fdfa2 --- /dev/null +++ b/.chloggen/ottl-timestamp.yaml @@ -0,0 +1,27 @@ +# Use this changelog template to create an entry for release notes. + +# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix' +change_type: enhancement + +# The name of the component, or a single word describing the area of concern, (e.g. filelogreceiver) +component: pkg/ottl + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: "Add the `FormatTime` function to convert `time.Time` values to human-readable strings" + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [36870] + +# (Optional) One or more lines of additional information to render under the primary note. +# These lines will be padded with 2 spaces and then inserted directly into the document. +# Use pipe (|) for multiline entries. +subtext: + +# If your change doesn't affect end users or the exported elements of any package, +# you should instead start your pull request title with [chore] or use the "Skip Changelog" label. +# Optional: The change log or logs in which this entry should be included. +# e.g. '[user]' or '[user, api]' +# Include 'user' if the change is relevant to end users. +# Include 'api' if there is a change to a library API. +# Default: '[user]' +change_logs: [user] diff --git a/pkg/ottl/e2e/e2e_test.go b/pkg/ottl/e2e/e2e_test.go index a11d78a72305..d552be6a8bd1 100644 --- a/pkg/ottl/e2e/e2e_test.go +++ b/pkg/ottl/e2e/e2e_test.go @@ -937,6 +937,12 @@ func Test_e2e_converters(t *testing.T) { tCtx.GetLogRecord().SetTimestamp(pcommon.NewTimestampFromTime(TestLogTimestamp.AsTime().Truncate(time.Second))) }, }, + { + statement: `set(attributes["time"], FormatTime(time, "%Y-%m-%d"))`, + want: func(tCtx ottllog.TransformContext) { + tCtx.GetLogRecord().Attributes().PutStr("time", "2020-02-11") + }, + }, { statement: `set(attributes["test"], "pass") where UnixMicro(time) > 0`, want: func(tCtx ottllog.TransformContext) { diff --git a/pkg/ottl/ottlfuncs/README.md b/pkg/ottl/ottlfuncs/README.md index ac86e36fc297..2dbb7792f5e7 100644 --- a/pkg/ottl/ottlfuncs/README.md +++ b/pkg/ottl/ottlfuncs/README.md @@ -422,6 +422,7 @@ Available Converters: - [ExtractGrokPatterns](#extractgrokpatterns) - [FNV](#fnv) - [Format](#format) +- [FormatTime](#formattime) - [GetXML](#getxml) - [Hex](#hex) - [Hour](#hour) @@ -806,6 +807,62 @@ Examples: - `Format("%04d-%02d-%02d", [Year(Now()), Month(Now()), Day(Now())])` - `Format("%s/%s/%04d-%02d-%02d.log", [attributes["hostname"], body["program"], Year(Now()), Month(Now()), Day(Now())])` +### FormatTime + +`FormatTime(time, format)` + +The `FormatTime` Converter takes a `time.Time` and converts it to a human-readable string representation of the time according to the specified format. + +`time` is `time.Time`. If `time` is another type an error is returned. `format` is a string. + +If either `time` or `format` are nil, an error is returned. The parser used is the parser at [internal/coreinternal/parser](https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/internal/coreinternal/timeutils). If `format` does not follow the parsing rules used by this parser, an error is returned. + +`format` denotes a human-readable textual representation of the resulting time value formatted according to ctime-like format string. It follows [standard Go Layout formatting](https://pkg.go.dev/time#pkg-constants) with few additional substitutes: +| substitution | description | examples | +|-----|-----|-----| +|`%Y` | Year as a zero-padded number | 0001, 0002, ..., 2019, 2020, ..., 9999 | +|`%y` | Year, last two digits as a zero-padded number | 01, ..., 99 | +|`%m` | Month as a zero-padded number | 01, 02, ..., 12 | +|`%o` | Month as a space-padded number | 1, 2, ..., 12 | +|`%q` | Month as an unpadded number | 1,2,...,12 | +|`%b`, `%h` | Abbreviated month name | Jan, Feb, ... | +|`%B` | Full month name | January, February, ... | +|`%d` | Day of the month as a zero-padded number | 01, 02, ..., 31 | +|`%e` | Day of the month as a space-padded number| 1, 2, ..., 31 | +|`%g` | Day of the month as a unpadded number | 1,2,...,31 | +|`%a` | Abbreviated weekday name | Sun, Mon, ... | +|`%A` | Full weekday name | Sunday, Monday, ... | +|`%H` | Hour (24-hour clock) as a zero-padded number | 00, ..., 24 | +|`%I` | Hour (12-hour clock) as a zero-padded number | 00, ..., 12 | +|`%l` | Hour 12-hour clock | 0, ..., 24 | +|`%p` | Locale’s equivalent of either AM or PM | AM, PM | +|`%P` | Locale’s equivalent of either am or pm | am, pm | +|`%M` | Minute as a zero-padded number | 00, 01, ..., 59 | +|`%S` | Second as a zero-padded number | 00, 01, ..., 59 | +|`%L` | Millisecond as a zero-padded number | 000, 001, ..., 999 | +|`%f` | Microsecond as a zero-padded number | 000000, ..., 999999 | +|`%s` | Nanosecond as a zero-padded number | 00000000, ..., 99999999 | +|`%z` | UTC offset in the form ±HHMM[SS[.ffffff]] or empty | +0000, -0400 | +|`%Z` | Timezone name or abbreviation or empty | UTC, EST, CST | +|`%i` | Timezone as +/-HH | -07 | +|`%j` | Timezone as +/-HH:MM | -07:00 | +|`%k` | Timezone as +/-HH:MM:SS | -07:00:00 | +|`%w` | Timezone as +/-HHMMSS | -070000 | +|`%D`, `%x` | Short MM/DD/YYYY date, equivalent to %m/%d/%y | 01/21/2031 | +|`%F` | Short YYYY-MM-DD date, equivalent to %Y-%m-%d | 2031-01-21 | +|`%T`,`%X` | ISO 8601 time format (HH:MM:SS), equivalent to %H:%M:%S | 02:55:02 | +|`%r` | 12-hour clock time | 02:55:02 pm | +|`%R` | 24-hour HH:MM time, equivalent to %H:%M | 13:55 | +|`%n` | New-line character ('\n') | | +|`%t` | Horizontal-tab character ('\t') | | +|`%%` | A % sign | | +|`%c` | Date and time representation | Mon Jan 02 15:04:05 2006 | + +Examples: + +- `FormatTime(Time("02/04/2023", "%m/%d/%Y"), "%A %h %e %Y")` +- `FormatTime(UnixNano(attributes["time_nanoseconds"]), "%b %d %Y %H:%M:%S")` +- `FormatTime(TruncateTime(time, Duration("10h 20m"))), "%Y-%m-%d %H:%M:%S")` ### GetXML diff --git a/pkg/ottl/ottlfuncs/func_formattime.go b/pkg/ottl/ottlfuncs/func_formattime.go new file mode 100644 index 000000000000..61bd7b89dd7e --- /dev/null +++ b/pkg/ottl/ottlfuncs/func_formattime.go @@ -0,0 +1,51 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package ottlfuncs // import "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl/ottlfuncs" + +import ( + "context" + "fmt" + + "github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal/timeutils" + "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl" +) + +type FormatTimeArguments[K any] struct { + Time ottl.TimeGetter[K] + Format string +} + +func NewFormatTimeFactory[K any]() ottl.Factory[K] { + return ottl.NewFactory("FormatTime", &FormatTimeArguments[K]{}, createFormatTimeFunction[K]) +} + +func createFormatTimeFunction[K any](_ ottl.FunctionContext, oArgs ottl.Arguments) (ottl.ExprFunc[K], error) { + args, ok := oArgs.(*FormatTimeArguments[K]) + + if !ok { + return nil, fmt.Errorf("FormatTimeFactory args must be of type *FormatTimeArguments[K]") + } + + return FormatTime(args.Time, args.Format) +} + +func FormatTime[K any](timeValue ottl.TimeGetter[K], format string) (ottl.ExprFunc[K], error) { + if format == "" { + return nil, fmt.Errorf("format cannot be nil") + } + + gotimeFormat, err := timeutils.StrptimeToGotime(format) + if err != nil { + return nil, err + } + + return func(ctx context.Context, tCtx K) (any, error) { + t, err := timeValue.Get(ctx, tCtx) + if err != nil { + return nil, err + } + + return t.Format(gotimeFormat), nil + }, nil +} diff --git a/pkg/ottl/ottlfuncs/func_formattime_test.go b/pkg/ottl/ottlfuncs/func_formattime_test.go new file mode 100644 index 000000000000..f31094431345 --- /dev/null +++ b/pkg/ottl/ottlfuncs/func_formattime_test.go @@ -0,0 +1,169 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package ottlfuncs + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl" +) + +func Test_FormatTime(t *testing.T) { + tests := []struct { + name string + time ottl.TimeGetter[any] + format string + expected string + errorMsg string + funcErrorMsg string + }{ + { + name: "empty format", + time: &ottl.StandardTimeGetter[any]{}, + format: "", + errorMsg: "format cannot be nil", + }, + { + name: "invalid time", + time: &ottl.StandardTimeGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return "something", nil + }, + }, + format: "%Y-%m-%d", + funcErrorMsg: "expected time but got string", + }, + { + name: "simple short form", + time: &ottl.StandardTimeGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return time.Date(2023, 4, 12, 0, 0, 0, 0, time.Local), nil + }, + }, + format: "%Y-%m-%d", + expected: "2023-04-12", + }, + { + name: "simple short form with short year and slashes", + time: &ottl.StandardTimeGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return time.Date(2011, 11, 11, 0, 0, 0, 0, time.Local), nil + }, + }, + format: "%d/%m/%y", + expected: "11/11/11", + }, + { + name: "month day year", + time: &ottl.StandardTimeGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return time.Date(2023, 2, 4, 0, 0, 0, 0, time.Local), nil + }, + }, + format: "%m/%d/%Y", + expected: "02/04/2023", + }, + { + name: "simple long form", + time: &ottl.StandardTimeGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return time.Date(1993, 7, 31, 0, 0, 0, 0, time.Local), nil + }, + }, + format: "%B %d, %Y", + expected: "July 31, 1993", + }, + { + name: "date with FormatTime", + time: &ottl.StandardTimeGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return time.Date(2023, 3, 14, 17, 0o2, 59, 0, time.Local), nil + }, + }, + format: "%b %d %Y %H:%M:%S", + expected: "Mar 14 2023 17:02:59", + }, + { + name: "day of the week long form", + time: &ottl.StandardTimeGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return time.Date(2023, 5, 1, 0, 0, 0, 0, time.Local), nil + }, + }, + format: "%A, %B %d, %Y", + expected: "Monday, May 01, 2023", + }, + { + name: "short weekday, short month, long format", + time: &ottl.StandardTimeGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return time.Date(2023, 5, 20, 0, 0, 0, 0, time.Local), nil + }, + }, + format: "%a, %b %d, %Y", + expected: "Sat, May 20, 2023", + }, + { + name: "short months", + time: &ottl.StandardTimeGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return time.Date(2023, 2, 15, 0, 0, 0, 0, time.Local), nil + }, + }, + format: "%b %d, %Y", + expected: "Feb 15, 2023", + }, + { + name: "simple short form with time", + time: &ottl.StandardTimeGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return time.Date(2023, 5, 26, 12, 34, 56, 0, time.Local), nil + }, + }, + format: "%Y-%m-%d %H:%M:%S", + expected: "2023-05-26 12:34:56", + }, + { + name: "RFC 3339 in custom format", + time: &ottl.StandardTimeGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return time.Date(2012, 11, 0o1, 22, 8, 41, 0, time.Local), nil + }, + }, + format: "%Y-%m-%dT%H:%M:%S", + expected: "2012-11-01T22:08:41", + }, + { + name: "RFC 3339 in custom format before 2000", + time: &ottl.StandardTimeGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return time.Date(1986, 10, 0o1, 0o0, 17, 33, 0o0, time.Local), nil + }, + }, + format: "%Y-%m-%dT%H:%M:%S", + expected: "1986-10-01T00:17:33", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exprFunc, err := FormatTime(tt.time, tt.format) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + assert.NoError(t, err) + result, err := exprFunc(nil, nil) + if tt.funcErrorMsg != "" { + assert.Contains(t, err.Error(), tt.funcErrorMsg) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + } + }) + } +} diff --git a/pkg/ottl/ottlfuncs/functions.go b/pkg/ottl/ottlfuncs/functions.go index d00bc578e04b..4aa7ffee9e3a 100644 --- a/pkg/ottl/ottlfuncs/functions.go +++ b/pkg/ottl/ottlfuncs/functions.go @@ -89,6 +89,7 @@ func converters[K any]() []ottl.Factory[K] { NewStringFactory[K](), NewSubstringFactory[K](), NewTimeFactory[K](), + NewFormatTimeFactory[K](), NewTrimFactory[K](), NewToKeyValueStringFactory[K](), NewTruncateTimeFactory[K](),