diff --git a/exporters/otlp/profiles/src/main/java/io/opentelemetry/exporter/otlp/profiles/SampleCompositionBuilder.java b/exporters/otlp/profiles/src/main/java/io/opentelemetry/exporter/otlp/profiles/SampleCompositionBuilder.java
new file mode 100644
index 00000000000..93faa49aca3
--- /dev/null
+++ b/exporters/otlp/profiles/src/main/java/io/opentelemetry/exporter/otlp/profiles/SampleCompositionBuilder.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.exporter.otlp.profiles;
+
+import io.opentelemetry.exporter.otlp.internal.data.ImmutableSampleData;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+
+/**
+ * Assembles a collection of state observations into a collection of SampleData objects i.e. proto
+ * Sample messages.
+ *
+ *
Observations (samples or traces from a profiler) having the same key can be merged to save
+ * space without loss of fidelity. On the wire, a single Sample, modelled in the API as a SampleData
+ * object, comprises shared fields (the key) and per-occurrence fields (the value and timestamp).
+ * This class maps the raw observations to the aggregations.
+ *
+ *
This class is not threadsafe and must be externally synchronized.
+ */
+public class SampleCompositionBuilder {
+
+ private final Map map = new HashMap<>();
+
+ /**
+ * Constructs a new collection of SampleData instances based on the builder's value.
+ *
+ * @return a new {@code List}
+ */
+ public List build() {
+ List result = new ArrayList<>(map.size());
+ for (Map.Entry entry : map.entrySet()) {
+ SampleCompositionKey key = entry.getKey();
+ SampleCompositionValue value = entry.getValue();
+ SampleData sampleData =
+ ImmutableSampleData.create(
+ key.getStackIndex(),
+ value.getValues(),
+ key.getAttributeIndices(),
+ key.getLinkIndex(),
+ value.getTimestamps());
+ result.add(sampleData);
+ }
+
+ return result;
+ }
+
+ /**
+ * Adds a new observation to the collection.
+ *
+ * @param key the shared ('primary key') fields of the observation.
+ * @param value the observed data point.
+ * @param timestamp the time of the observation.
+ */
+ public void add(SampleCompositionKey key, @Nullable Long value, @Nullable Long timestamp) {
+ SampleCompositionValue v = map.computeIfAbsent(key, key1 -> new SampleCompositionValue());
+ v.add(value, timestamp);
+ }
+}
diff --git a/exporters/otlp/profiles/src/main/java/io/opentelemetry/exporter/otlp/profiles/SampleCompositionKey.java b/exporters/otlp/profiles/src/main/java/io/opentelemetry/exporter/otlp/profiles/SampleCompositionKey.java
new file mode 100644
index 00000000000..8a7abab23d4
--- /dev/null
+++ b/exporters/otlp/profiles/src/main/java/io/opentelemetry/exporter/otlp/profiles/SampleCompositionKey.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.exporter.otlp.profiles;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import javax.annotation.concurrent.Immutable;
+
+/**
+ * A SampleCompositionKey represents the identity portion of an aggregation of observed data.
+ * Observations (samples) having the same key can be merged to save space without loss of fidelity.
+ */
+@Immutable
+public class SampleCompositionKey {
+
+ // on the wire, a Sample's identity (i.e. 'primary key') is the tuple of
+ // {stack_index, sorted(attribute_indices), link_index}
+ private final int stackIndex;
+ private final List attributeIndices;
+ private final int linkIndex;
+
+ public SampleCompositionKey(int stackIndex, List attributeIndices, int linkIndex) {
+ this.stackIndex = stackIndex;
+ List tmp = new ArrayList<>(attributeIndices);
+ Collections.sort(tmp);
+ this.attributeIndices = Collections.unmodifiableList(tmp);
+ this.linkIndex = linkIndex;
+ }
+
+ public int getStackIndex() {
+ return stackIndex;
+ }
+
+ public List getAttributeIndices() {
+ return attributeIndices;
+ }
+
+ public int getLinkIndex() {
+ return linkIndex;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof SampleCompositionKey)) {
+ return false;
+ }
+ SampleCompositionKey that = (SampleCompositionKey) o;
+ return stackIndex == that.stackIndex
+ && linkIndex == that.linkIndex
+ && Objects.equals(attributeIndices, that.attributeIndices);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(stackIndex, attributeIndices, linkIndex);
+ }
+}
diff --git a/exporters/otlp/profiles/src/main/java/io/opentelemetry/exporter/otlp/profiles/SampleCompositionValue.java b/exporters/otlp/profiles/src/main/java/io/opentelemetry/exporter/otlp/profiles/SampleCompositionValue.java
new file mode 100644
index 00000000000..90a6041d1aa
--- /dev/null
+++ b/exporters/otlp/profiles/src/main/java/io/opentelemetry/exporter/otlp/profiles/SampleCompositionValue.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.exporter.otlp.profiles;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import javax.annotation.Nullable;
+
+/**
+ * A SampleCompositionValue represents the per-observation parts of an aggregation of observed data.
+ * Observations (samples) having the same key can be merged by appending their distinct fields to
+ * the composed value.
+ *
+ * This class is not threadsafe and must be externally synchronized.
+ */
+public class SampleCompositionValue {
+
+ private final List values = new ArrayList<>();
+ private final List timestamps = new ArrayList<>();
+
+ public List getValues() {
+ return Collections.unmodifiableList(values);
+ }
+
+ public List getTimestamps() {
+ return Collections.unmodifiableList(timestamps);
+ }
+
+ /**
+ * Add a new observation to the collection.
+ *
+ * Note that, whilst not enforced by the API, it is required that all observations in a
+ * collection share the same 'shape'. That is, they have either a value without timestamp, a
+ * timestamp without value, or both timestamp and value. Thus each array (values, timestamps) in
+ * the collection is either zero length, or the same length as the other.
+ *
+ * @param value the observed data point.
+ * @param timestamp the time of the observation.
+ */
+ public void add(@Nullable Long value, @Nullable Long timestamp) {
+ if (value != null) {
+ values.add(value);
+ }
+ if (timestamp != null) {
+ timestamps.add(timestamp);
+ }
+ }
+}
diff --git a/exporters/otlp/profiles/src/test/java/io/opentelemetry/exporter/otlp/profiles/SampleCompositionTest.java b/exporters/otlp/profiles/src/test/java/io/opentelemetry/exporter/otlp/profiles/SampleCompositionTest.java
new file mode 100644
index 00000000000..a4ae1f75f8a
--- /dev/null
+++ b/exporters/otlp/profiles/src/test/java/io/opentelemetry/exporter/otlp/profiles/SampleCompositionTest.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.exporter.otlp.profiles;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class SampleCompositionTest {
+
+ SampleCompositionBuilder sampleCompositionBuilder;
+
+ @BeforeEach
+ void setUp() {
+ sampleCompositionBuilder = new SampleCompositionBuilder();
+ }
+
+ @Test
+ public void empty() {
+ assertThat(sampleCompositionBuilder.build()).isEmpty();
+ }
+
+ @Test
+ public void keyEquality() {
+ SampleCompositionKey a;
+ SampleCompositionKey b;
+
+ a = new SampleCompositionKey(1, listOf(2, 3), 4);
+ b = new SampleCompositionKey(1, listOf(2, 3), 4);
+ assertThat(a).isEqualTo(b);
+ assertThat(a.hashCode()).isEqualTo(b.hashCode());
+
+ b = new SampleCompositionKey(1, listOf(3, 2), 4);
+ assertThat(a).isEqualTo(b);
+ assertThat(a.hashCode()).isEqualTo(b.hashCode());
+
+ b = new SampleCompositionKey(2, listOf(2, 3), 4);
+ assertThat(a).isNotEqualTo(b);
+ }
+
+ @Test
+ public void valueElidesNulls() {
+ SampleCompositionValue v = new SampleCompositionValue();
+ v.add(1L, 1L);
+ v.add(null, 2L);
+ v.add(2L, null);
+ assertThat(v.getValues().size()).isEqualTo(2);
+ assertThat(v.getTimestamps().size()).isEqualTo(2);
+ }
+
+ @Test
+ public void isAggregatingSameKey() {
+ SampleCompositionKey sampleCompositionKey =
+ new SampleCompositionKey(0, Collections.emptyList(), 0);
+ sampleCompositionBuilder.add(sampleCompositionKey, 1L, 1L);
+ sampleCompositionBuilder.add(sampleCompositionKey, 2L, 2L);
+
+ List sampleDataList = sampleCompositionBuilder.build();
+ assertThat(sampleDataList).size().isEqualTo(1);
+ assertThat(sampleDataList.get(0).getTimestamps().size()).isEqualTo(2);
+ assertThat(sampleDataList.get(0).getValues().size()).isEqualTo(2);
+ }
+
+ @Test
+ public void isNotAggregatingDifferentKey() {
+ SampleCompositionKey keyA = new SampleCompositionKey(1, Collections.emptyList(), 0);
+ sampleCompositionBuilder.add(keyA, 1L, 1L);
+ SampleCompositionKey keyB = new SampleCompositionKey(2, Collections.emptyList(), 0);
+ sampleCompositionBuilder.add(keyB, 2L, 2L);
+
+ List sampleDataList = sampleCompositionBuilder.build();
+ assertThat(sampleDataList).size().isEqualTo(2);
+ assertThat(sampleDataList.get(0).getTimestamps().size()).isEqualTo(1);
+ assertThat(sampleDataList.get(1).getTimestamps().size()).isEqualTo(1);
+ }
+
+ private static List listOf(T a, T b) {
+ ArrayList list = new ArrayList<>();
+ list.add(a);
+ list.add(b);
+ return Collections.unmodifiableList(list);
+ }
+}