Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .chloggen/47465-opensearch-dotted-attribute-conflict.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Use this changelog template to create an entry for release notes.

# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
change_type: bug_fix

# The name of the component, or a single word describing the area of concern, (e.g. receiver/filelog)
component: exporter/opensearch

# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
note: Avoid OpenSearch mapping conflicts (mapper_parsing_exception) when an attribute is used both as a value and as an object prefix.

# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists.
issues: [47465]

# (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: |
OpenSearch expands dots in JSON field names into nested objects during dynamic
mapping, so a flat OTel attribute map containing both a concrete key (e.g.
`code.function`) and a longer key that uses it as an object prefix (e.g.
`code.function.name`) was rejected. The exporter now relocates the conflicting
concrete value under a `.value` sub-key (`code.function` -> `code.function.value`)
so the document is indexable. This applies to span, event, link, log-record,
metric data-point, exemplar and instrumentation-scope attributes in the ss4o
and otel-v1 mapping modes. Only documents that would otherwise be rejected are
changed.

# 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]
35 changes: 35 additions & 0 deletions .chloggen/47465-opensearch-ss4o-manage-index-template.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# 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. receiver/filelog)
component: exporter/opensearch

# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
note: Support `mapping.manage_index_template` in the `ss4o` mapping mode to install index templates that map attribute bags as `flat_object`.

# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists.
issues: [47465]

# (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: |
Previously `manage_index_template` was only valid with `otel-v1`. In `ss4o`
mode the exporter now installs traces and logs index templates that map the
attribute-bearing objects as `flat_object`, so OpenSearch stops expanding dots
in attribute keys into nested objects. This deterministically prevents
dotted-attribute mapping conflicts across documents. Note that `flat_object`
indexes all values as strings, so type-aware (e.g. numeric range) queries on
attribute values are not available; the option is opt-in and defaults to
`false`.

# 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]
14 changes: 13 additions & 1 deletion exporter/opensearchexporter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ The OpenSearch exporter supports several document schemas and preprocessing beha
- `flatten_attributes`: Uses the ECS mapping but flattens all resource and log attributes in the record to the top-level.
- `bodymap`: uses the "body" of a log record as the exact content of the OpenSearch document, without any transformation. This mapping mode is intended for use cases where the client wishes to have complete control over the OpenSearch document structure.
- `otel-v1`: exports logs and traces using the Data Prepper OTel v1 schema, compatible with OpenSearch Observability dashboards.
- `manage_index_template`: (optional, default=`false`) When `true`, creates composable index templates on startup. Only valid with `otel-v1` mode.
- `manage_index_template`: (optional, default=`false`) When `true`, creates composable index templates on startup. Only valid with `otel-v1` and `ss4o` modes. In `ss4o` mode the templates map attribute bags as `flat_object` to avoid dotted-attribute mapping conflicts (see [SS4O mapping mode](#ss4o-mapping-mode)).
- `timestamp_field`: (optional) Field to store the timestamp in. If not set, uses the default `@timestamp`.
- `unix_timestamp`: (optional) Whether to store the timestamp in epoch milliseconds.
- `dedup`: (optional) removes fields from the document, that have duplicate keys. The filtering only keeps the last value for a key.
Expand All @@ -142,6 +142,18 @@ Metrics follow the [SS4O metrics schema](https://github.com/opensearch-project/o
>
> The catalog template maps `value@int` as a 32-bit `integer`; OpenTelemetry integer data points are 64-bit, so values beyond ±2³¹ are rejected. Change that field to `long` in the template before installing it ([Data Prepper's reference template](https://github.com/opensearch-project/data-prepper/blob/main/data-prepper-plugins/opensearch/src/main/resources/index-template/metrics-otel-v1-index-standard-template.json) already uses `long`).

##### Dotted attribute keys and mapping conflicts

OpenTelemetry attributes are a flat namespace in which `code.function` and `code.function.name` are two distinct keys. OpenSearch, however, expands dots in JSON field names into nested objects during dynamic mapping. When both keys are present, OpenSearch tries to map `attributes.code.function` as *both* a concrete value and an object and rejects the document with a `mapper_parsing_exception`. This commonly happens while migrating between semantic-convention versions (e.g. `code.function` → `code.function.name`).

The exporter handles this in two complementary ways:

- **Within a document (always on):** if an attribute map contains both a concrete key and a longer key that uses it as an object prefix, the concrete value is relocated under a `.value` sub-key (`code.function` → `code.function.value`) so the document is indexable. This applies to span, event, link, log-record, metric data-point, exemplar and instrumentation-scope attributes (in the `ss4o` and `otel-v1` modes), and only changes documents OpenSearch would otherwise reject.
- **Across documents (opt-in):** set `manage_index_template: true` to have the exporter install traces and logs index templates that map the attribute-bearing objects (`attributes`, `resource`, scope/event/link attributes) as [`flat_object`](https://docs.opensearch.org/latest/mappings/supported-field-types/flat-object/). OpenSearch then stores attribute bags without expanding dots, which prevents conflicts even when the conflicting keys arrive in separate documents.

> [!NOTE]
> `flat_object` indexes every leaf value as a string, so type-aware queries (for example numeric range queries) on attribute values are not available. The template only matches the default `ss4o_traces-*` / `ss4o_logs-*` index patterns; if you configure a custom `traces_index`/`logs_index`, install an equivalent template out-of-band.

#### ECS mapping mode

> [!WARNING]
Expand Down
16 changes: 13 additions & 3 deletions exporter/opensearchexporter/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ var (
errMetricsIndexTimeFormatInvalid = errors.New("metrics_index_time_format contains unsupported or invalid tokens")
errOTelV1DatasetNamespaceUnused = errors.New(`dataset and namespace are not used by mapping.mode "otel-v1"; remove them or pick a different mode`)
errMetricsMappingModeUnsupported = errors.New(`metrics are only supported by mapping.mode "ss4o" and "otel-v1"`)
errManageIndexTemplateInvalidMode = errors.New("mapping.manage_index_template is only supported with mapping.mode \"otel-v1\"")
errManageIndexTemplateInvalidMode = errors.New("mapping.manage_index_template is only supported with mapping.mode \"otel-v1\" or \"ss4o\"")
)

type MappingsSettings struct {
Expand Down Expand Up @@ -114,7 +114,15 @@ type MappingsSettings struct {
Mode string `mapstructure:"mode"`

// ManageIndexTemplate controls whether the exporter creates index templates on startup.
// Only supported when Mode is "otel-v1". Validation will reject this option with other modes.
// Only supported when Mode is "otel-v1" or "ss4o". Validation will reject this option
// with other modes.
//
// In "ss4o" mode the templates map attribute bags (attributes, resource and scope/event/
// link attributes) as flat_object so OpenSearch does not expand dots in attribute keys
// into nested objects, preventing mapper_parsing_exception conflicts between an attribute
// used as a value ("code.function") and the same prefix used as an object
// ("code.function.name"). Note that flat_object indexes all values as strings, so
// type-aware (e.g. numeric range) queries on attribute values are not available.
ManageIndexTemplate bool `mapstructure:"manage_index_template"`

// Additional field mappings.
Expand Down Expand Up @@ -234,7 +242,9 @@ func (cfg *Config) Validate() error {
multiErr = append(multiErr, errMappingModeInvalid)
}

if cfg.MappingsSettings.ManageIndexTemplate && cfg.MappingsSettings.Mode != MappingOTelV1.String() {
if cfg.MappingsSettings.ManageIndexTemplate &&
cfg.MappingsSettings.Mode != MappingOTelV1.String() &&
cfg.MappingsSettings.Mode != MappingSS4O.String() {
multiErr = append(multiErr, errManageIndexTemplateInvalidMode)
}

Expand Down
2 changes: 1 addition & 1 deletion exporter/opensearchexporter/config.schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ $defs:
description: File to read additional fields mappings from.
type: string
manage_index_template:
description: ManageIndexTemplate controls whether the exporter creates index templates on startup. Only supported when Mode is "otel-v1". Validation will reject this option with other modes.
description: 'ManageIndexTemplate controls whether the exporter creates index templates on startup. Only supported when Mode is "otel-v1" or "ss4o". Validation will reject this option with other modes. In "ss4o" mode the templates map attribute bags as flat_object so OpenSearch does not expand dots in attribute keys into nested objects, preventing mapper_parsing_exception conflicts; note that flat_object indexes all values as strings, so type-aware queries on attribute values are not available.'
type: boolean
mode:
description: 'Mode configures the field mappings. Supported modes are the following: ss4o: exports logs in the Simple Schema for Observability standard. This mode is enabled by default. See: https://opensearch.org/docs/latest/observing-your-data/ss4o/ ecs: maps fields defined in the OpenTelemetry Semantic Conventions to the Elastic Common Schema. See: https://www.elastic.co/guide/en/ecs/current/index.html flatten_attributes: uses the ECS mapping but flattens all resource and log attributes in the record to the top-level. bodymap: supports only logs and uses the "body" of a log record as the exact content of the OpenSearch document, without any transformation. This mapping mode is intended for use cases where the client wishes to have complete control over the OpenSearch document structure. otel-v1: exports logs and traces using the OTel v1 schema published by the OpenSearch Data Prepper project (https://github.com/opensearch-project/data-prepper). Documents are compatible with OpenSearch Observability dashboards that consume Data Prepper indices. See the upstream index templates for the canonical field mappings: https://github.com/opensearch-project/data-prepper/blob/main/data-prepper-plugins/opensearch/src/main/resources/index-template/otel-v1-apm-span-index-standard-template.json https://github.com/opensearch-project/data-prepper/blob/main/data-prepper-plugins/opensearch/src/main/resources/index-template/logs-otel-v1-index-standard-template.json'
Expand Down
13 changes: 9 additions & 4 deletions exporter/opensearchexporter/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -354,15 +354,20 @@ func TestOTelV1MappingModeValidation(t *testing.T) {
manageTpl: true,
},
{
name: "ss4o with manage_index_template true is invalid",
mode: "ss4o",
manageTpl: true,
expectError: errManageIndexTemplateInvalidMode.Error(),
name: "ss4o with manage_index_template true is valid",
mode: "ss4o",
manageTpl: true,
},
{
name: "ss4o with manage_index_template false is valid",
mode: "ss4o",
},
{
name: "ecs with manage_index_template true is invalid",
mode: "ecs",
manageTpl: true,
expectError: errManageIndexTemplateInvalidMode.Error(),
},
}

for _, tt := range tests {
Expand Down
88 changes: 88 additions & 0 deletions exporter/opensearchexporter/encoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"

"go.opentelemetry.io/collector/pdata/pcommon"
Expand All @@ -22,6 +24,80 @@

var errInvalidTypeForBodyMapMode = errors.New("invalid log record body type for 'bodymap' mapping mode")

// resolveAttributeKeyConflicts rewrites attribute keys that would otherwise
// cause an OpenSearch mapping conflict.
//
// OpenSearch expands dots in JSON field names into nested objects during
// dynamic mapping. A flat OTel attribute map that contains both a concrete key
// (e.g. "code.function") and a longer key that uses it as an object prefix
// (e.g. "code.function.name") makes OpenSearch try to map
// "attributes.code.function" as both a concrete value and an object, which it
// rejects with a mapper_parsing_exception. This is common while migrating
// between semantic-convention versions (code.function -> code.function.name).
//
// To keep the document indexable, the concrete value is moved under a ".value"
// sub-key ("code.function" -> "code.function.value"), mirroring the behaviour

Check failure on line 39 in exporter/opensearchexporter/encoder.go

View workflow job for this annotation

GitHub Actions / scoped-tests-matrix (windows-2025)

`behaviour` is a misspelling of `behavior` (misspell)

Check failure on line 39 in exporter/opensearchexporter/encoder.go

View workflow job for this annotation

GitHub Actions / lint-matrix (windows, exporter/opensearchexporter)

`behaviour` is a misspelling of `behavior` (misspell)

Check failure on line 39 in exporter/opensearchexporter/encoder.go

View workflow job for this annotation

GitHub Actions / lint-matrix (linux, exporter/opensearchexporter)

`behaviour` is a misspelling of `behavior` (misspell)
// of the ECS mapping mode's objmodel.Dedup step. The rewrite only triggers when
// a conflicting sibling is present in the same map, i.e. only for documents
// OpenSearch would otherwise reject, so well-formed documents are unchanged.
// It recurses into nested maps and arrays of maps so conflicts within map-typed
// attribute values are handled too.
func resolveAttributeKeyConflicts(m map[string]any) {
if len(m) == 0 {
return
}

// Handle nested maps and arrays of maps first.
for _, v := range m {
switch vv := v.(type) {
case map[string]any:
resolveAttributeKeyConflicts(vv)
case []any:
for _, e := range vv {
if em, ok := e.(map[string]any); ok {
resolveAttributeKeyConflicts(em)
}
}
}
}

// Repeatedly rename the shortest conflicting key until the map is stable.
// Renaming can, in pathological cases, create a new adjacency, so re-scan
// with a fresh, sorted key set after each rename.
for {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)

renamed := false
for i := 0; i < len(keys)-1; i++ {
key, next := keys[i], keys[i+1]
// next must use key as a strict, dot-delimited object prefix.
if len(key) >= len(next) || !strings.HasPrefix(next, key) || next[len(key)] != '.' {
continue
}
// Only a concrete (non-object) value conflicts with the prefix use.
if _, isObj := m[key].(map[string]any); isObj {
continue
}
target := key + ".value"
if _, exists := m[target]; !exists {
m[target] = m[key]
}
// If target already exists it is an object being built from other
// keys; drop the concrete value rather than clobber it.
delete(m, key)
renamed = true
break
}
if !renamed {
return
}
}
}

type mappingModel interface {
encodeLog(resource pcommon.Resource,
scope pcommon.InstrumentationScope,
Expand Down Expand Up @@ -130,6 +206,7 @@
) ([]byte, error) {
sso := ssoRecord{}
sso.Attributes = record.Attributes().AsRaw()
resolveAttributeKeyConflicts(sso.Attributes)
sso.Body = record.Body().AsString()
sso.EventName = record.EventName()

Expand Down Expand Up @@ -161,6 +238,7 @@
sso.InstrumentationScope.Version = scope.Version()
sso.InstrumentationScope.SchemaURL = schemaURL
sso.InstrumentationScope.Attributes = scope.Attributes().AsRaw()
resolveAttributeKeyConflicts(sso.InstrumentationScope.Attributes)

sso.Severity.Text = record.SeverityText()
sso.Severity.Number = int64(record.SeverityNumber())
Expand Down Expand Up @@ -226,6 +304,7 @@

sso := ssoSpan{}
sso.Attributes = span.Attributes().AsRaw()
resolveAttributeKeyConflicts(sso.Attributes)
sso.DroppedAttributesCount = span.DroppedAttributesCount()
sso.DroppedEventsCount = span.DroppedEventsCount()
sso.DroppedLinksCount = span.DroppedLinksCount()
Expand All @@ -247,6 +326,7 @@
e := span.Events().At(i)
ssoEvent := &sso.Events[i]
ssoEvent.Attributes = e.Attributes().AsRaw()
resolveAttributeKeyConflicts(ssoEvent.Attributes)
ssoEvent.DroppedAttributesCount = e.DroppedAttributesCount()
ssoEvent.Name = e.Name()
ts := e.Timestamp().AsTime()
Expand Down Expand Up @@ -278,13 +358,15 @@
sso.InstrumentationScope.Version = scope.Version()
sso.InstrumentationScope.SchemaURL = schemaURL
sso.InstrumentationScope.Attributes = scope.Attributes().AsRaw()
resolveAttributeKeyConflicts(sso.InstrumentationScope.Attributes)

if span.Links().Len() > 0 {
sso.Links = make([]ssoSpanLinks, span.Links().Len())
for i := 0; i < span.Links().Len(); i++ {
link := span.Links().At(i)
ssoLink := &sso.Links[i]
ssoLink.Attributes = link.Attributes().AsRaw()
resolveAttributeKeyConflicts(ssoLink.Attributes)
ssoLink.DroppedAttributesCount = link.DroppedAttributesCount()
ssoLink.TraceID = link.TraceID().String()
ssoLink.TraceState = link.TraceState().AsRaw()
Expand Down Expand Up @@ -330,6 +412,8 @@
DroppedAttributesCount: scope.DroppedAttributesCount(),
},
}
resolveAttributeKeyConflicts(doc.Attributes)
resolveAttributeKeyConflicts(doc.InstrumentationScope.Attributes)
return json.Marshal(doc)
}

Expand Down Expand Up @@ -378,6 +462,8 @@
DroppedAttributesCount: scope.DroppedAttributesCount(),
},
}
resolveAttributeKeyConflicts(doc.Attributes)
resolveAttributeKeyConflicts(doc.InstrumentationScope.Attributes)

// Extract serviceName from resource attributes
if sn, ok := resource.Attributes().Get("service.name"); ok {
Expand Down Expand Up @@ -405,6 +491,7 @@
DroppedAttributesCount: e.DroppedAttributesCount(),
Time: e.Timestamp().AsTime(),
}
resolveAttributeKeyConflicts(doc.Events[i].Attributes)
}
}

Expand All @@ -420,6 +507,7 @@
Attributes: l.Attributes().AsRaw(),
DroppedAttributesCount: l.DroppedAttributesCount(),
}
resolveAttributeKeyConflicts(doc.Links[i].Attributes)
}
}

Expand Down
Loading
Loading