Repository: microsoft/go-infra
Pull request #255: Implement Application Insights package
Author: login: qmuntal
Created: 2025-05-05T13:20:49Z | Merged: 2025-05-14T18:30:31Z

This PR contains a trimmed down version of https://github.com/microsoft/ApplicationInsights-Go.
It is tailored for the use of the [Microsoft build of Go](https://github.com/microsoft/go).

These are the changes made to the original package:

- Remove all external dependencies.
- Remove all telemetry types except for `Event`.
- Simplify the API.
- Modernize the code.
- Telemetry events no longer have a unique operation UUID (we don't care about it).
- A bug has been fixed that leaked a goroutine when closing the client.
- The API is now more similar to the one in https://github.com/golang/telemetry.
- The time mocking for tests now uses the experimental `tetsing/synctest` package instead of an external library (requires Go 1.24). This simplified the code and makes testing more robust (it found the aforementioned bug). See https://go.dev/blog/synctest for more info.
- The code is more performant now
- Test have been expanded and improved.

A side benefit is that now vendoring this module only takes 1k LoC instead of the previous 17k LoC.

Usage example:

```go
package main

import (
  "context"

  "github.com/microsoft/go-infra/telemetry/internal/appinsights"
)

var client = &appinsights.Client{InstrumentationKey: "fake_key"}

var counter = client.NewEvent("test event")

func main() {
  counter.Inc()
  client.TrackEvent("another event")
  client.Close(context.Background())
}
```

This are the `TrackEvent` call performance improvements taking the official SDK as baseline:

```
goos: windows
goarch: amd64
pkg: github.com/microsoft/go-infra/telemetry/internal/appinsights
cpu: Intel(R) Core(TM) i7-10850H CPU @ 2.70GHz
                          │   old.txt    │              new.txt               │
                          │    sec/op    │   sec/op     vs base               │
ClientBurstPerformance-12   4028.0n ± 9%   867.7n ± 9%  -78.46% (p=0.001 n=7)

                          │   old.txt   │              new.txt              │
                          │    B/op     │    B/op     vs base               │
ClientBurstPerformance-12   3004.0 ± 0%   748.0 ± 3%  -75.10% (p=0.001 n=7)

                          │   old.txt   │              new.txt              │
                          │  allocs/op  │ allocs/op   vs base               │
ClientBurstPerformance-12   21.000 ± 0%   2.000 ± 0%  -90.48% (p=0.001 n=7)
```



Diff:
diff --git a/.github/workflows/install-script-pwsh-test.yml b/.github/workflows/install-script-pwsh-test.yml
index 8d6ef01a..53c9988d 100644
--- a/.github/workflows/install-script-pwsh-test.yml
+++ b/.github/workflows/install-script-pwsh-test.yml
@@ -21,7 +21,6 @@ jobs:
           - windows-latest
           - windows-2019
           - ubuntu-latest
-          - ubuntu-20.04
     runs-on: ${{ matrix.os }}
     steps:
       # Intentionally get upstream Go. This allows us to run install script
diff --git a/eng/pipelines/pr-pipeline.yml b/eng/pipelines/pr-pipeline.yml
index c4175276..e3f5aaa4 100644
--- a/eng/pipelines/pr-pipeline.yml
+++ b/eng/pipelines/pr-pipeline.yml
@@ -13,7 +13,7 @@ jobs:
       clean: all
     pool:
       # Use generic recent LTS.
-      vmImage: ubuntu-20.04
+      vmImage: ubuntu-22.04
     steps:
       - template: steps/checkout-unix-task.yml
       - template: steps/init-go.yml
@@ -22,6 +22,13 @@ jobs:
       - script: |
           go test -json ./... > $(Build.StagingDirectory)/TestResults.json
         displayName: Test
+        
+      - script: |
+          go test -json -race ./... > $(Build.StagingDirectory)/TestResults.json
+        displayName: Test Telemetry
+        workingDirectory: $(Build.SourcesDirectory)/telemetry
+        env:
+          GOEXPERIMENT: synctest
 
       - script: |
           go run ./cmd/json2junit -in $(Build.StagingDirectory)/TestResults.json -out $(Build.StagingDirectory)/TestResults.xml
@@ -49,7 +56,7 @@ jobs:
     parameters:
       name: TestFuzz1x
       pool:
-        vmImage: ubuntu-20.04
+        vmImage: ubuntu-22.04
       platform: linux-amd64
       useMicrosoftGo: false
       # 1x runs the tests only once. It uses the first entry in the corpus so won't generate any
diff --git a/eng/pipelines/steps/init-go.yml b/eng/pipelines/steps/init-go.yml
index e06e97b3..3a5c566c 100644
--- a/eng/pipelines/steps/init-go.yml
+++ b/eng/pipelines/steps/init-go.yml
@@ -6,4 +6,4 @@
 steps:
   - task: GoTool@0
     inputs:
-      version: 1.23.1
+      version: 1.24.1
diff --git a/telemetry/README.md b/telemetry/README.md
new file mode 100644
index 00000000..da3674fd
--- /dev/null
+++ b/telemetry/README.md
@@ -0,0 +1,3 @@
+# telemetry
+
+This directory contains the telemetry transmission code for the Microsoft build of Go.
diff --git a/telemetry/go.mod b/telemetry/go.mod
new file mode 100644
index 00000000..8e219cc3
--- /dev/null
+++ b/telemetry/go.mod
@@ -0,0 +1,3 @@
+module github.com/microsoft/go-infra/telemetry
+
+go 1.24
diff --git a/telemetry/go.sum b/telemetry/go.sum
new file mode 100644
index 00000000..e69de29b
diff --git a/telemetry/internal/appinsights/README.md b/telemetry/internal/appinsights/README.md
new file mode 100644
index 00000000..2e96890f
--- /dev/null
+++ b/telemetry/internal/appinsights/README.md
@@ -0,0 +1,12 @@
+# appinsights
+
+This package is a trimmed down version of https://github.com/microsoft/ApplicationInsights-Go.
+It is tailored for the use of the [Microsoft build of Go](https://github.com/microsoft/go).
+
+These are the changes made to the original package:
+
+- Remove all external dependencies.
+- Remove all telemetry types except for `Event`.
+- Simplify implementation.
+- Modernize the code.
+- Improve testing.
diff --git a/telemetry/internal/appinsights/client.go b/telemetry/internal/appinsights/client.go
new file mode 100644
index 00000000..1a804f49
--- /dev/null
+++ b/telemetry/internal/appinsights/client.go
@@ -0,0 +1,170 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package appinsights
+
+import (
+	"cmp"
+	"context"
+	"log"
+	"maps"
+	"net/http"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+// Client is the main entry point for sending telemetry to Application Insights.
+// Changing its properties after a telemetry item is created will have no effect.
+type Client struct {
+	// The instrumentation key used to identify the application.
+	// This key is required and must be set before sending any telemetry.
+	InstrumentationKey string
+
+	// The endpoint URL to which telemetry will be sent.
+	// If empty, it defaults to https://dc.services.visualstudio.com/v2/track.
+	Endpoint string
+
+	// Maximum number of telemetry items that can be submitted in each
+	// request. If this many items are buffered, the buffer will be
+	// flushed before MaxBatchInterval expires.
+	// If zero, it defaults to 1024.
+	MaxBatchSize int
+
+	// Maximum time to wait before sending a batch of telemetry.
+	// If zero, it defaults to 10 seconds.
+	MaxBatchInterval time.Duration
+
+	// Customized http client.
+	// If nil, it defaults to http.DefaultClient.
+	HTTPClient *http.Client
+
+	// Tags to be sent with every telemetry item.
+	// If nil, no additional tags will be sent.
+	Tags map[string]string
+
+	// ErrorLog specifies an optional logger for errors
+	// that occur when attempting to upload telemetry.
+	// If nil, logging is done via the log package's standard logger.
+	ErrorLog *log.Logger
+
+	channel  *inMemoryChannel
+	context  *telemetryContext
+	disabled bool
+
+	initialized atomic.Bool
+	initOnce    sync.Once
+}
+
+// init initializes the client.
+// It is safe to call this method multiple times concurrently.
+func (c *Client) init() {
+	c.initOnce.Do(func() {
+		if c.InstrumentationKey == "" {
+			panic("instrumentation key is required")
+		}
+		endpoint := cmp.Or(c.Endpoint, "https://dc.services.visualstudio.com/v2/track")
+		batchSize := cmp.Or(c.MaxBatchSize, 1024)
+		batchInterval := cmp.Or(c.MaxBatchInterval, 10*time.Second)
+		httpClient := cmp.Or(c.HTTPClient, http.DefaultClient)
+		c.channel = newInMemoryChannel(endpoint, batchSize, batchInterval, httpClient, c.ErrorLog)
+		c.context = setupContext(c.InstrumentationKey, c.Tags)
+		if err := contracts.SanitizeTags(c.context.Tags); err != nil {
+			c.channel.logf("Warning sanitizing tags: %v", err)
+		}
+
+		go c.channel.acceptLoop()
+		c.initialized.Store(true)
+	})
+}
+
+func setupContext(instrumentationKey string, tags map[string]string) *telemetryContext {
+	context := newTelemetryContext(instrumentationKey)
+	context.Tags["ai.internal.sdkVersion"] = internalVersion
+	maps.Copy(context.Tags, tags)
+	return context
+}
+
+// Enabled returns true if this client is enabled and will accept telemetry.
+func (c *Client) Enabled() bool {
+	return !c.disabled
+}
+
+// SetEnabled enables or disables the client. When disabled, telemetry is
+// silently swallowed by the client. Defaults to enabled.
+func (c *Client) SetEnabled(enabled bool) {
+	c.disabled = !enabled
+}
+
+// NewEvent creates a new event with the specified name.
+func (c *Client) NewEvent(name string) *Event {
+	return &Event{
+		name:   name,
+		client: c,
+	}
+}
+
+// TrackEvent logs a user action with the specified name.
+func (c *Client) TrackEvent(name string) {
+	c.NewEvent(name).Inc()
+}
+
+// Forces the current queue to be sent.
+func (c *Client) Flush() {
+	if !c.initialized.Load() {
+		return
+	}
+	c.channel.flush()
+}
+
+// Close flushes and tears down the submission goroutine and closes internal channels.
+// Waits until all pending telemetry items have been submitted.
+func (c *Client) Close(ctx context.Context) {
+	if !c.initialized.Load() {
+		return
+	}
+	c.channel.close(ctx)
+}
+
+// Stop tears down the submission goroutines, closes internal channels.
+// Any telemetry waiting to be sent is discarded.
+// This is a more abrupt version of [Client.Close].
+func (c *Client) Stop() {
+	c.channel.stop()
+}
+
+// Submits the specified telemetry item.
+func (c *Client) track(data contracts.EventData, n uint64) {
+	if c.disabled || n == 0 {
+		return
+	}
+	c.init()
+	ev := c.context.envelop(data)
+	if err := ev.Sanitize(); err != nil {
+		c.channel.logf("Warning sanitizing telemetry item: %v", err)
+	}
+	for range n {
+		c.channel.send(ev)
+	}
+}
+
+// Event represents an event to be tracked.
+type Event struct {
+	name   string
+	client *Client
+}
+
+// Inc adds 1 to the counter.
+func (e *Event) Inc() {
+	e.Add(1)
+}
+
+// Add adds n to the counter. n cannot be negative, as counts cannot decrease.
+func (e *Event) Add(n uint64) {
+	e.client.track(contracts.EventData{
+		Name: e.name,
+		Ver:  2,
+	}, n)
+}
diff --git a/telemetry/internal/appinsights/client_test.go b/telemetry/internal/appinsights/client_test.go
new file mode 100644
index 00000000..1356f03c
--- /dev/null
+++ b/telemetry/internal/appinsights/client_test.go
@@ -0,0 +1,396 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//go:build goexperiment.synctest
+
+package appinsights_test
+
+import (
+	"bytes"
+	"io"
+	"log"
+	"net/http"
+	"testing"
+	"testing/synctest"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights"
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/appinsightstest"
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+const test_ikey = "01234567-0000-89ab-cdef-000000000000"
+
+type testPlan struct {
+	maxBatchSize     int
+	maxBatchInterval time.Duration
+	actions          []appinsightstest.Action
+	responses        []appinsightstest.ServerResponse
+}
+
+func (plan testPlan) run(t *testing.T) {
+	synctest.Run(func() {
+		client := appinsightstest.New(t, plan.actions, plan.responses...)
+		client.SetMaxBatchSize(plan.maxBatchSize)
+		client.SetMaxBatchInterval(plan.maxBatchInterval)
+
+		client.Act()
+		client.Close(t.Context())
+	})
+}
+
+func TestClientNoUpload(t *testing.T) {
+	plan := testPlan{
+		maxBatchSize: 2,
+		actions: []appinsightstest.Action{
+			{Type: appinsightstest.TrackAction},
+		},
+	}
+	plan.run(t)
+}
+
+func TestClientBatch(t *testing.T) {
+	plan := testPlan{
+		maxBatchSize: 2,
+		actions: []appinsightstest.Action{
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.TrackAction}, // this one is ignored
+		},
+		responses: []appinsightstest.ServerResponse{
+			{StatusCode: http.StatusOK, EventIndices: []int{0, 1}},
+		},
+	}
+	plan.run(t)
+}
+
+func TestClientBatchMultiple(t *testing.T) {
+	plan := testPlan{
+		maxBatchSize: 2,
+		actions: []appinsightstest.Action{
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.TrackAction}, // this one is ignored
+		},
+		responses: []appinsightstest.ServerResponse{
+			{StatusCode: http.StatusOK, EventIndices: []int{0, 1}},
+			{StatusCode: http.StatusOK, EventIndices: []int{2, 3}},
+		},
+	}
+	plan.run(t)
+}
+
+func TestClientInterval(t *testing.T) {
+	plan := testPlan{
+		maxBatchInterval: 1 * time.Second,
+		actions: []appinsightstest.Action{
+			{Type: appinsightstest.TrackAction, Delay: 500 * time.Millisecond},
+			{Type: appinsightstest.TrackAction, Delay: 500 * time.Millisecond},
+			{Type: appinsightstest.TrackAction, Delay: 500 * time.Millisecond}, // this one is ignored
+		},
+		responses: []appinsightstest.ServerResponse{
+			{StatusCode: http.StatusOK, EventIndices: []int{0, 1}},
+		},
+	}
+	plan.run(t)
+}
+
+func TestClientIntervalMultiple(t *testing.T) {
+	plan := testPlan{
+		maxBatchInterval: 1 * time.Second,
+		actions: []appinsightstest.Action{
+			{Type: appinsightstest.TrackAction, Delay: 500 * time.Millisecond},
+			{Type: appinsightstest.TrackAction, Delay: 500 * time.Millisecond},
+			{Type: appinsightstest.TrackAction, Delay: 1 * time.Second},
+			{Type: appinsightstest.TrackAction, Delay: 500 * time.Second}, // this one is ignored
+		},
+		responses: []appinsightstest.ServerResponse{
+			{StatusCode: http.StatusOK, EventIndices: []int{0, 1}},
+			{StatusCode: http.StatusOK, EventIndices: []int{2}},
+		},
+	}
+	plan.run(t)
+}
+
+func TestClientFlush(t *testing.T) {
+	plan := testPlan{
+		maxBatchSize: 2,
+		actions: []appinsightstest.Action{
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.FlushAction},
+		},
+		responses: []appinsightstest.ServerResponse{
+			{StatusCode: http.StatusOK, EventIndices: []int{0, 1}},
+		},
+	}
+	plan.run(t)
+}
+
+func TestClientFlushMultiple(t *testing.T) {
+	plan := testPlan{
+		actions: []appinsightstest.Action{
+			{Type: appinsightstest.FlushAction}, // does nothing
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.FlushAction},
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.FlushAction},
+		},
+		responses: []appinsightstest.ServerResponse{
+			{StatusCode: http.StatusOK, EventIndices: []int{0}},
+			{StatusCode: http.StatusOK, EventIndices: []int{1}},
+		},
+	}
+	plan.run(t)
+}
+
+func TestClientBatchIntervalFlush(t *testing.T) {
+	plan := testPlan{
+		maxBatchSize:     2,
+		maxBatchInterval: 1 * time.Second,
+		actions: []appinsightstest.Action{
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.TrackAction, Delay: 1 * time.Second},        // uploads 0 due to interval
+			{Type: appinsightstest.TrackAction},                                // uploads 1 and 2 due to batch
+			{Type: appinsightstest.TrackAction, Delay: 500 * time.Millisecond}, // uploads nothing
+			{Type: appinsightstest.TrackAction, Delay: 500 * time.Millisecond}, // uploads 3 and 4
+			{Type: appinsightstest.TrackAction},                                // uploads nothing
+			{Type: appinsightstest.TrackAction, Delay: 2 * time.Second},        // uploads 5
+			{Type: appinsightstest.FlushAction},                                // uploads 6
+			{Type: appinsightstest.TrackAction},                                // ignored
+		},
+		responses: []appinsightstest.ServerResponse{
+			{StatusCode: http.StatusOK, EventIndices: []int{0}},    // interval
+			{StatusCode: http.StatusOK, EventIndices: []int{1, 2}}, // batch
+			{StatusCode: http.StatusOK, EventIndices: []int{3, 4}}, // batch
+			{StatusCode: http.StatusOK, EventIndices: []int{5}},    // interval
+			{StatusCode: http.StatusOK, EventIndices: []int{6}},    // flush
+		},
+	}
+	plan.run(t)
+}
+
+func TestClientFailNoRetry(t *testing.T) {
+	plan := testPlan{
+		actions: []appinsightstest.Action{
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.FlushAction},
+		},
+		responses: []appinsightstest.ServerResponse{
+			{StatusCode: http.StatusBadRequest, EventIndices: []int{0, 1}},
+		},
+	}
+	plan.run(t)
+}
+
+func TestClientFailRetryAndSucceed(t *testing.T) {
+	plan := testPlan{
+		maxBatchInterval: 1 * time.Second,
+		actions: []appinsightstest.Action{
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.FlushAction},
+			{Type: appinsightstest.SleepAction, Delay: 5 * time.Second},
+		},
+		responses: []appinsightstest.ServerResponse{
+			{StatusCode: http.StatusInternalServerError, EventIndices: []int{0, 1}},
+			{StatusCode: http.StatusOK, EventIndices: []int{0, 1}},
+		},
+	}
+	plan.run(t)
+}
+
+func TestClientFailRetry(t *testing.T) {
+	// Verify that the events are only retried 3 times.
+	plan := testPlan{
+		maxBatchInterval: 1 * time.Second,
+		actions: []appinsightstest.Action{
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.FlushAction},
+			{Type: appinsightstest.SleepAction, Delay: 5 * time.Second},
+		},
+		responses: []appinsightstest.ServerResponse{
+			{StatusCode: http.StatusInternalServerError, EventIndices: []int{0, 1}},
+			{StatusCode: http.StatusInternalServerError, EventIndices: []int{0, 1}},
+			{StatusCode: http.StatusInternalServerError, EventIndices: []int{0, 1}},
+			{StatusCode: http.StatusInternalServerError, EventIndices: []int{0, 1}},
+		},
+	}
+	plan.run(t)
+}
+
+func TestClientPartialFailNoRetry(t *testing.T) {
+	plan := testPlan{
+		maxBatchInterval: 1 * time.Second,
+		actions: []appinsightstest.Action{
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.FlushAction},
+			{Type: appinsightstest.SleepAction, Delay: 5 * time.Second},
+		},
+		responses: []appinsightstest.ServerResponse{
+			{
+				StatusCode:   http.StatusPartialContent,
+				EventIndices: []int{0, 1},
+				Errors: []contracts.BackendResponseError{
+					{Index: 0, StatusCode: http.StatusOK},
+					{Index: 1, StatusCode: http.StatusBadRequest},
+				},
+			},
+		},
+	}
+	plan.run(t)
+}
+
+func TestClientPartialFailRetryAndSucceed(t *testing.T) {
+	plan := testPlan{
+		maxBatchInterval: 1 * time.Second,
+		actions: []appinsightstest.Action{
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.FlushAction},
+			{Type: appinsightstest.SleepAction, Delay: 5 * time.Second},
+		},
+		responses: []appinsightstest.ServerResponse{
+			{
+				StatusCode:   http.StatusPartialContent,
+				EventIndices: []int{0, 1},
+				Errors: []contracts.BackendResponseError{
+					{Index: 0, StatusCode: http.StatusInternalServerError},
+					{Index: 1, StatusCode: http.StatusOK},
+				},
+			},
+			{StatusCode: http.StatusOK, EventIndices: []int{0}},
+		},
+	}
+	plan.run(t)
+}
+
+func TestClientPartialFailRetry(t *testing.T) {
+	plan := testPlan{
+		maxBatchInterval: 1 * time.Second,
+		actions: []appinsightstest.Action{
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.FlushAction},
+			{Type: appinsightstest.SleepAction, Delay: 5 * time.Second},
+		},
+		responses: []appinsightstest.ServerResponse{
+			{
+				StatusCode:   http.StatusPartialContent,
+				EventIndices: []int{0, 1},
+				Errors: []contracts.BackendResponseError{
+					{Index: 0, StatusCode: http.StatusInternalServerError},
+					{Index: 1, StatusCode: http.StatusOK},
+				},
+			},
+			{StatusCode: http.StatusInternalServerError, EventIndices: []int{0}},
+			{StatusCode: http.StatusInternalServerError, EventIndices: []int{0}},
+			{StatusCode: http.StatusInternalServerError, EventIndices: []int{0}},
+		},
+	}
+	plan.run(t)
+}
+
+func TestClientFlushAfterStop(t *testing.T) {
+	plan := testPlan{
+		maxBatchSize: 2,
+		actions: []appinsightstest.Action{
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.StopAction},
+			{Type: appinsightstest.FlushAction},
+		},
+	}
+	plan.run(t)
+}
+
+func TestClientThrottle(t *testing.T) {
+	plan := testPlan{
+		maxBatchSize:     1,
+		maxBatchInterval: 1 * time.Second,
+		actions: []appinsightstest.Action{
+			{Type: appinsightstest.TrackAction}, // throttled
+			{Type: appinsightstest.FlushAction},
+			{Type: appinsightstest.TrackAction, Delay: 5 * time.Second}, // dropped
+			{Type: appinsightstest.FlushAction},
+			{Type: appinsightstest.CloseAction},
+		},
+		responses: []appinsightstest.ServerResponse{
+			{
+				StatusCode:   http.StatusTooManyRequests,
+				EventIndices: []int{0},
+				RetryDelay:   5 * time.Second,
+			},
+			{StatusCode: http.StatusOK, EventIndices: []int{0}},
+		},
+	}
+	plan.run(t)
+}
+
+func TestClientRetriableStatusCodes(t *testing.T) {
+	plan := testPlan{
+		maxBatchInterval: 1 * time.Second,
+		actions: []appinsightstest.Action{
+			{Type: appinsightstest.TrackAction},
+			{Type: appinsightstest.FlushAction},
+			{Type: appinsightstest.TrackAction, Delay: 2 * time.Second},
+			{Type: appinsightstest.FlushAction},
+			{Type: appinsightstest.TrackAction, Delay: 2 * time.Second},
+			{Type: appinsightstest.FlushAction},
+			{Type: appinsightstest.TrackAction, Delay: 2 * time.Second},
+			{Type: appinsightstest.FlushAction},
+			{Type: appinsightstest.TrackAction, Delay: 2 * time.Second},
+			{Type: appinsightstest.FlushAction},
+			{Type: appinsightstest.SleepAction, Delay: 2 * time.Second},
+		},
+		responses: []appinsightstest.ServerResponse{
+			{StatusCode: http.StatusRequestTimeout, EventIndices: []int{0}},
+			{StatusCode: http.StatusOK, EventIndices: []int{0}},
+			{StatusCode: http.StatusInternalServerError, EventIndices: []int{1}},
+			{StatusCode: http.StatusOK, EventIndices: []int{1}},
+			{StatusCode: http.StatusServiceUnavailable, EventIndices: []int{2}},
+			{StatusCode: http.StatusOK, EventIndices: []int{2}},
+			{StatusCode: http.StatusTooManyRequests, EventIndices: []int{3}},
+			{StatusCode: http.StatusOK, EventIndices: []int{3}},
+			{StatusCode: 439, EventIndices: []int{4}}, // TooManyRequestsOverExtendedTimeResponse
+			{StatusCode: http.StatusOK, EventIndices: []int{4}},
+		},
+	}
+	plan.run(t)
+}
+
+type fakeTransport struct {
+	code int
+	body []byte
+	err  error
+}
+
+func (t fakeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+	return &http.Response{
+		StatusCode: t.code,
+		Body:       io.NopCloser(bytes.NewBuffer(t.body)),
+	}, t.err
+}
+
+func BenchmarkClientBurstPerformance(b *testing.B) {
+	client := &appinsights.Client{
+		InstrumentationKey: test_ikey,
+		ErrorLog:           log.New(io.Discard, "", 0),
+		HTTPClient: &http.Client{
+			Transport: fakeTransport{
+				code: http.StatusOK,
+				body: []byte(`{"itemsReceived":4, "itemsAccepted":4, "errors":[]}`),
+			},
+		},
+	}
+
+	for b.Loop() {
+		client.TrackEvent("A message")
+	}
+
+	client.Close(b.Context())
+}
diff --git a/telemetry/internal/appinsights/inmemorychannel.go b/telemetry/internal/appinsights/inmemorychannel.go
new file mode 100644
index 00000000..14ef7b6f
--- /dev/null
+++ b/telemetry/internal/appinsights/inmemorychannel.go
@@ -0,0 +1,314 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package appinsights
+
+import (
+	"context"
+	"errors"
+	"log"
+	"net/http"
+	"slices"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+// batchItem is a telemetry item that is sent in a batch.
+type batchItem struct {
+	item    *contracts.Envelope
+	retries int
+}
+
+// inMemoryChannel stores events exclusively in memory.
+// Presently the only telemetry channel implementation available.
+type inMemoryChannel struct {
+	endpointAddr  string
+	batchSize     int
+	batchInterval time.Duration
+	errorLog      *log.Logger
+
+	collectChan chan *contracts.Envelope
+	flushChan   chan struct{}
+	retryChan   chan retryMessage
+
+	transmitter transmitter
+
+	// Use a context instead of a channel to
+	// allow propagating the cancellation to the
+	// transmitter and to the underlying HTTP client.
+	cancelCtx       context.Context
+	cancelCauseFunc context.CancelCauseFunc
+
+	throttled   atomic.Bool
+	closed      atomic.Bool
+	sendQueue   []*[]batchItem
+	sendQueueMu sync.Mutex
+	sendMu      sync.Mutex
+
+	itemsBuf sync.Pool
+}
+
+type retryMessage struct {
+	throttled  bool
+	retryAfter time.Time
+	items      []batchItem
+}
+
+// newInMemoryChannel creates an inMemoryChannel instance and starts a background submission goroutine.
+func newInMemoryChannel(endpointUrl string, batchSize int, batchInterval time.Duration, httpClient *http.Client, errorLog *log.Logger) *inMemoryChannel {
+	// Set up the channel
+	channel := &inMemoryChannel{
+		endpointAddr:  endpointUrl,
+		batchSize:     batchSize,
+		batchInterval: batchInterval,
+		errorLog:      errorLog,
+		collectChan:   make(chan *contracts.Envelope),
+		flushChan:     make(chan struct{}),
+		retryChan:     make(chan retryMessage),
+		transmitter:   newTransmitter(endpointUrl, httpClient),
+		itemsBuf: sync.Pool{
+			New: func() any {
+				buf := make([]batchItem, 0, batchSize)
+				return &buf
+			},
+		},
+	}
+	channel.cancelCtx, channel.cancelCauseFunc = context.WithCancelCause(context.Background())
+	return channel
+}
+
+func (channel *inMemoryChannel) logf(format string, args ...any) {
+	if channel.errorLog != nil {
+		channel.errorLog.Printf(format, args...)
+	} else {
+		log.Printf(format, args...)
+	}
+}
+
+// Queues a single telemetry item
+func (channel *inMemoryChannel) send(item *contracts.Envelope) {
+	if item != nil && !channel.closed.Load() {
+		channel.collectChan <- item
+	}
+}
+
+// Forces the current queue to be sent
+func (channel *inMemoryChannel) flush() {
+	if channel.closed.Load() {
+		return
+	}
+	channel.flushChan <- struct{}{}
+}
+
+func (channel *inMemoryChannel) retry(throttled bool, retryAfter time.Time, items []batchItem) {
+	// Retry even if the channel is closed to allow for
+	// retrying items that were already sent.
+	channel.retryChan <- retryMessage{throttled, retryAfter, items}
+}
+
+var errStopped = errors.New("client stopped")
+var errClosed = errors.New("client closed")
+
+func (channel *inMemoryChannel) stop() {
+	if channel.closed.Load() {
+		return
+	}
+
+	channel.closed.Store(true)
+	channel.cancelCauseFunc(errStopped)
+}
+
+// close flushes and tears down the submission goroutine and closes internal
+// channels. Returns when all pending telemetry items have been submitted
+// (it is then safe to shut down without losing telemetry) or when
+// the context is canceled.
+func (channel *inMemoryChannel) close(ctx context.Context) {
+	if channel.closed.Load() {
+		return
+	}
+
+	channel.closed.Store(true)
+	channel.flushChan <- struct{}{}
+	select {
+	case <-ctx.Done():
+		channel.cancelCauseFunc(context.Cause(ctx))
+	case <-channel.cancelCtx.Done():
+		// Successfully flushed
+	}
+}
+
+func (channel *inMemoryChannel) acceptLoop() {
+	channel.start()
+}
+
+// Part of channel accept loop: Initialize buffer and accept first message, handle controls.
+func (channel *inMemoryChannel) start() {
+	items := make([]batchItem, 0, channel.batchSize)
+	timer := time.NewTimer(time.Hour)
+	timer.Stop() // Stop timer until we need it.
+	var dropped int
+	for {
+		select {
+		case item := <-channel.collectChan:
+			if item == nil {
+				panic("received nil event")
+			}
+			if channel.throttled.Load() {
+				// Check if there is space to add the item to the batch.
+				// If not, then drop the event.
+				if len(items) < channel.batchSize {
+					items = append(items, batchItem{item, 0})
+				} else {
+					dropped++
+				}
+				continue
+			}
+			items = append(items, batchItem{item, 0})
+			if len(items) >= channel.batchSize {
+				timer.Stop()
+				channel.sendBatch(items)
+				items = items[:0]
+			} else if len(items) == 1 {
+				// Start the timer if this is the first item in the batch.
+				timer.Reset(channel.batchInterval)
+			}
+
+		case <-channel.flushChan:
+			if channel.throttled.Load() {
+				// Ignore the flush request if we are throttled.
+				continue
+			}
+			timer.Stop()
+			channel.sendBatch(items)
+			items = items[:0]
+
+		case <-timer.C:
+			if channel.throttled.Load() {
+				// When throttled, the timer is reset to the retry time,
+				// so if we get here, then we're no longer throttled.
+				channel.throttled.Store(false)
+				if dropped > 0 {
+					channel.logf("Dropped %d telemetry items due to throttling", dropped)
+					dropped = 0
+				}
+			}
+			channel.sendBatch(items)
+			items = items[:0]
+
+		case msg := <-channel.retryChan:
+			// If there is not enough space in the batch, drop the items.
+			space := channel.batchSize - len(items)
+			if space < len(msg.items) {
+				dropped += len(msg.items) - space
+				msg.items = msg.items[:space]
+			}
+			items = append(items, msg.items...)
+
+			if msg.throttled {
+				channel.throttled.Store(true)
+			}
+			if msg.retryAfter.IsZero() {
+				// If the retry time is not set, use the default batch interval.
+				timer.Reset(channel.batchInterval)
+			} else {
+				timer.Reset(time.Until(msg.retryAfter))
+			}
+
+		case <-channel.cancelCtx.Done():
+			// This is the only path to exit the loop.
+			timer.Stop()
+			return
+		}
+	}
+}
+
+// sendBatch schedules a batch of items for transmission.
+func (channel *inMemoryChannel) sendBatch(items []batchItem) {
+	channel.sendQueueMu.Lock()
+	defer channel.sendQueueMu.Unlock()
+
+	if len(items) == 0 {
+		// Cancel the accept loop if we are closed and the queue is empty.
+		if len(channel.sendQueue) == 0 && channel.closed.Load() {
+			channel.cancelCauseFunc(errClosed)
+		}
+		return
+	}
+
+	// Copy the items to a temporary buffer to let the caller
+	// reuse the item slice. The size of items is capped to the
+	// maximum batch size, so the length of the polled buffer
+	// can't grow unbounded.
+	buf := channel.itemsBuf.Get().(*[]batchItem)
+	*buf = (*buf)[:0]
+	*buf = append(*buf, items...)
+	channel.sendQueue = append(channel.sendQueue, buf)
+
+	// Start a goroutine to transmit the items without blocking
+	// the accept loop.
+	go func() {
+		retry := channel.transmitRetry()
+		channel.sendQueueMu.Lock()
+		defer channel.sendQueueMu.Unlock()
+		if !retry && len(channel.sendQueue) == 0 && channel.closed.Load() {
+			// Cancel the accept loop if we are closed and the queue is empty.
+			channel.cancelCauseFunc(errClosed)
+		}
+	}()
+}
+
+// transmitRetry pops the first item from the queue and transmits it.
+// If the transmission fails, it retries the items that can be retried.
+// Returns true if some items were retried.
+func (channel *inMemoryChannel) transmitRetry() bool {
+	// Allow only one goroutine to transmit at a time.
+	channel.sendMu.Lock()
+	defer channel.sendMu.Unlock()
+
+	// Pop the first item from the queue.
+	channel.sendQueueMu.Lock()
+	itemsPtr := channel.sendQueue[0]
+	channel.sendQueue = channel.sendQueue[1:]
+	channel.sendQueueMu.Unlock()
+	defer channel.itemsBuf.Put(itemsPtr)
+
+	result, err := channel.transmitter.transmit(channel.cancelCtx, *itemsPtr)
+	if err != nil {
+		channel.logf("Error transmitting payload: %v", err)
+		if result == nil {
+			return false
+		}
+	}
+	if result.isSuccess() {
+		return false
+	}
+	canRetry := result.canRetry()
+	throttled := result.isThrottled()
+	channel.logf("Failed to transmit payload: code=%v, received=%d, accepted=%d, canRetry=%t, throttled=%t",
+		result.statusCode, result.response.ItemsReceived, result.response.ItemsAccepted, canRetry, throttled)
+
+	if !canRetry {
+		return false
+	}
+
+	// Filter down to failed items.
+	retryItems := result.getRetryItems(*itemsPtr)
+
+	// Delete items that have been retried more than 2 times.
+	retryItems = slices.DeleteFunc(retryItems, func(item batchItem) bool {
+		return item.retries > 2
+	})
+	if len(retryItems) == 0 {
+		return false
+	}
+	for i := range retryItems {
+		item := &retryItems[i]
+		item.retries++
+	}
+
+	channel.retry(throttled, result.retryAfter, retryItems)
+	return true
+}
diff --git a/telemetry/internal/appinsights/internal/appinsightstest/appinsightstest.go b/telemetry/internal/appinsights/internal/appinsightstest/appinsightstest.go
new file mode 100644
index 00000000..6bec2a85
--- /dev/null
+++ b/telemetry/internal/appinsights/internal/appinsightstest/appinsightstest.go
@@ -0,0 +1,383 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+// This package contains test utilities for the Application Insights SDK.
+package appinsightstest
+
+import (
+	"bytes"
+	"compress/gzip"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"log"
+	"net"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"strconv"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"testing"
+	"testing/synctest"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights"
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+const test_ikey = "01234567-0000-89ab-cdef-000000000000"
+
+var envelopeName = "Microsoft.ApplicationInsights." + strings.Replace(test_ikey, "-", "", -1) + ".Event"
+
+// ActionType represents the type of action to be performed by the client.
+type ActionType int
+
+const (
+	TrackAction ActionType = iota
+	FlushAction
+	StopAction
+	CloseAction
+	SleepAction
+)
+
+// Action represents an action to be performed by the client.
+type Action struct {
+	Type    ActionType
+	Delay   time.Duration
+	Context context.Context
+}
+
+func (a Action) do(c *Client) {
+	if a.Delay != 0 {
+		time.Sleep(a.Delay)
+		synctest.Wait()
+	}
+
+	c.n++
+	switch a.Type {
+	case TrackAction:
+		name := "msg_" + strconv.Itoa(c.n)
+		c.server.mu.Lock()
+		c.server.events = append(c.server.events, newEvent(name))
+		c.server.mu.Unlock()
+		c.client.TrackEvent(name)
+	case FlushAction:
+		c.client.Flush()
+	case StopAction:
+		c.client.Stop()
+	case CloseAction:
+		if a.Context == nil {
+			a.Context = context.Background()
+		}
+		c.client.Close(a.Context)
+	case SleepAction:
+		// Slept above.
+	default:
+		panic(fmt.Sprintf("unknown action type: %d", a.Type))
+	}
+}
+
+func newEvent(name string) *contracts.Envelope {
+	return &contracts.Envelope{
+		IKey: test_ikey,
+		Name: envelopeName,
+		Time: time.Now().UTC(),
+		Tags: map[string]string{
+			"ai.internal.sdkVersion": "go-infra/telemetry:" + appinsights.Version,
+		},
+		Ver:        1,
+		SampleRate: 100.0,
+		Data: contracts.Data{
+			BaseType: "EventData",
+			BaseData: contracts.EventData{
+				Name: name,
+				Ver:  2,
+			},
+		},
+	}
+}
+
+// Client is a test client for the Application Insights SDK.
+type Client struct {
+	t       *testing.T
+	client  *appinsights.Client
+	server  *testServer
+	actions []Action
+	buf     *bytes.Buffer
+
+	n      int
+	closed bool
+}
+
+// New creates a new test client with the specified actions and responses.
+// The client will be configured with a test server that will respond to the
+// actions with the specified responses. The client will also be configured
+// with a buffer to capture any errors that occur during the test.
+// The client max batch size and max batch interval are disabled by default
+// to avoid unintended uploads during the test. Use [Client.SetMaxBatchSize]
+// and [Client.SetMaxBatchInterval] to set them to a specific value.
+func New(t *testing.T, actions []Action, responses ...ServerResponse) *Client {
+	errbuf := &bytes.Buffer{}
+	server := newTestServer(t, responses...)
+	client := &appinsights.Client{
+		InstrumentationKey: test_ikey,
+		Endpoint:           fmt.Sprintf("%s/v2/track", server.srv.URL),
+		HTTPClient:         server.srv.Client(),
+		ErrorLog:           log.New(errbuf, "", 0),
+		MaxBatchSize:       1024,
+		MaxBatchInterval:   100 * time.Hour,
+	}
+	return &Client{
+		t:       t,
+		client:  client,
+		server:  server,
+		buf:     errbuf,
+		actions: actions,
+	}
+}
+
+// SetMaxBatchSize sets the maximum number of telemetry items that can be
+// submitted in each request.
+func (c *Client) SetMaxBatchSize(size int) {
+	if size > 0 {
+		c.client.MaxBatchSize = size
+	}
+}
+
+// SetMaxBatchInterval sets the maximum time to wait before sending a batch
+// of telemetry items.
+func (c *Client) SetMaxBatchInterval(interval time.Duration) {
+	if interval > 0 {
+		c.client.MaxBatchInterval = interval
+	}
+}
+
+// Act executes all the client actions.
+func (c *Client) Act() {
+	for _, action := range c.actions {
+		action.do(c)
+	}
+}
+
+// Close closes the client and waits for all actions to be executed.
+func (c *Client) Close(ctx context.Context) {
+	if c.closed {
+		return
+	}
+	rem := len(c.actions) - c.n
+	if rem > 0 {
+		c.t.Errorf("not all actions were executed, remaining: %d, total: %d", rem, len(c.actions))
+	}
+	// Close the server before the client to ensure that we detect outstanding requests as errors.
+	c.server.close()
+	c.client.Close(ctx)
+	c.closed = true
+}
+
+// ErrorOutput returns the error output captured during the test.
+func (c *Client) ErrorOutput() string {
+	return c.buf.String()
+}
+
+// ServerResponse represents a response from the test server.
+type ServerResponse struct {
+	StatusCode int
+	RetryDelay time.Duration
+	Errors     []contracts.BackendResponseError
+
+	// indices of the testServer.eventIndices that should be sent by the client.
+	EventIndices []int
+}
+
+// testServer is a test server that simulates the Application Insights
+// backend. It can be used to test the client by sending requests and
+// verifying the responses.
+type testServer struct {
+	t   *testing.T
+	srv *httptest.Server
+
+	done      chan struct{}
+	events    []*contracts.Envelope
+	responses []ServerResponse
+	n         int
+	closed    atomic.Bool
+	mu        sync.Mutex // protects server properties
+}
+
+func newTestServer(t *testing.T, responses ...ServerResponse) *testServer {
+	server := &testServer{
+		t:         t,
+		responses: responses,
+		done:      make(chan struct{}),
+	}
+	if len(responses) == 0 {
+		// No responses, close the done channel immediately.
+		close(server.done)
+	}
+
+	srvConn, cliConn := net.Pipe()
+	li := &fakeListener{
+		ch:   make(chan net.Conn, 1),
+		addr: srvConn.LocalAddr(),
+	}
+	li.ch <- srvConn
+	close(li.ch)
+	server.srv = &httptest.Server{
+		Config: &http.Server{
+			Handler: server,
+		},
+		Listener: li,
+	}
+	// Start the server
+	server.srv.Start()
+	server.srv.Client().Transport.(*http.Transport).DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
+		return cliConn, nil
+	}
+	t.Cleanup(func() {
+		srvConn.Close()
+		cliConn.Close()
+		server.srv.Close()
+	})
+	return server
+}
+
+func (srv *testServer) close() {
+	select {
+	case <-srv.done:
+	case <-time.After(1 * time.Second):
+		srv.mu.Lock()
+		rem := len(srv.responses) - srv.n
+		if rem > 0 {
+			srv.t.Errorf("not all responses were consumed, remaining: %d, total: %d", rem, len(srv.responses))
+		}
+		srv.mu.Unlock()
+	}
+	srv.srv.Close()
+	srv.closed.Store(true)
+}
+
+func (srv *testServer) ServeHTTP(writer http.ResponseWriter, req *http.Request) {
+	if srv.closed.Load() {
+		// Can happen if the client is closed with an outstanding request.
+		srv.t.Log("server closed, ignoring request")
+		return
+	}
+	defer func() {
+		srv.mu.Lock()
+		defer srv.mu.Unlock()
+		if srv.n >= len(srv.responses) {
+			srv.done <- struct{}{}
+		} else {
+			srv.n++
+		}
+	}()
+	if srv.n >= len(srv.responses) {
+		srv.t.Errorf("unexpected request, no more responses available")
+		writer.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	// Check if the request is a POST
+	resp := srv.responses[srv.n]
+
+	if req.Method != http.MethodPost {
+		srv.t.Errorf("[%d] request.Method want POST, got %s", srv.n, req.Method)
+	}
+
+	if encoding := req.Header.Get("Content-Encoding"); encoding != "gzip" {
+		srv.t.Errorf("[%d] request.Content-Encoding want gzip, got %s", srv.n, encoding)
+	}
+
+	// Decompress payload
+	reader, err := gzip.NewReader(req.Body)
+	if err != nil {
+		srv.t.Errorf("[%d] couldn't create gzip reader: %v", srv.n, err)
+	}
+	defer reader.Close()
+	dec := json.NewDecoder(reader)
+	var items []*contracts.Envelope
+	for {
+		var item contracts.Envelope
+		if err := dec.Decode(&item); err != nil {
+			if errors.Is(err, io.EOF) {
+				break
+			}
+			srv.t.Errorf("[%d] couldn't decode payload: %v", srv.n, err)
+			break
+		}
+		items = append(items, &item)
+	}
+
+	if len(items) != len(resp.EventIndices) {
+		srv.t.Errorf("[%d] request body length mismatch, want %d, got %d", srv.n, len(resp.EventIndices), len(items))
+	} else {
+		srv.mu.Lock()
+		for i, item := range items {
+			if i >= len(resp.EventIndices) {
+				srv.t.Errorf("[%d] item %d index out of range, want %d", srv.n, i, len(resp.EventIndices))
+				continue
+			}
+			prefix := fmt.Sprintf("[%d] item %d", srv.n, i)
+			compareEnvelopes(srv.t, prefix, item, srv.events[resp.EventIndices[i]])
+		}
+		srv.mu.Unlock()
+	}
+
+	result := contracts.BackendResponse{
+		ItemsReceived: len(items),
+		ItemsAccepted: len(items) - len(resp.Errors),
+		Errors:        resp.Errors,
+	}
+	body, err := json.Marshal(result)
+	if err != nil {
+		srv.t.Errorf("[%d] couldn't encode response: %v", srv.n, err)
+	}
+
+	writer.Header().Set("Content-Type", "application/json")
+	if resp.RetryDelay != 0 {
+		writer.Header().Set("Retry-After", time.Now().Add(resp.RetryDelay).Format(http.TimeFormat))
+	}
+
+	writer.WriteHeader(resp.StatusCode)
+	writer.Write(body)
+}
+
+func check(t *testing.T, prefix string, got, want any) {
+	t.Helper()
+	if !reflect.DeepEqual(got, want) {
+		t.Errorf("%s mismatch, want %v, got %v", prefix, want, got)
+	}
+}
+
+func compareEnvelopes(t *testing.T, prefix string, got, want *contracts.Envelope) {
+	t.Helper()
+	check(t, prefix+" iKey", got.IKey, want.IKey)
+	check(t, prefix+" name", got.Name, want.Name)
+	check(t, prefix+" time", got.Time, want.Time)
+	check(t, prefix+" sampleRate", got.SampleRate, want.SampleRate)
+	check(t, prefix+" ver", got.Ver, want.Ver)
+	check(t, prefix+" seq", got.Seq, want.Seq)
+	check(t, prefix+" tags", got.Tags, want.Tags)
+	check(t, prefix+" data.baseType", got.Data.BaseType, want.Data.BaseType)
+	check(t, prefix+" data.baseData", got.Data.BaseData, want.Data.BaseData)
+}
+
+type fakeListener struct {
+	ch   chan net.Conn
+	addr net.Addr
+}
+
+func (li *fakeListener) Accept() (net.Conn, error) {
+	conn := <-li.ch
+	if conn == nil {
+		return nil, net.ErrClosed
+	}
+	return conn, nil
+}
+
+func (li *fakeListener) Close() error   { return nil }
+func (li *fakeListener) Addr() net.Addr { return li.addr }
diff --git a/telemetry/internal/appinsights/internal/contracts/contexttagkeys.go b/telemetry/internal/appinsights/internal/contracts/contexttagkeys.go
new file mode 100644
index 00000000..58b0d5dc
--- /dev/null
+++ b/telemetry/internal/appinsights/internal/contracts/contexttagkeys.go
@@ -0,0 +1,50 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package contracts
+
+import (
+	"errors"
+	"fmt"
+)
+
+type ContextTags map[string]string
+
+var tagMaxLengths = map[string]int{
+	"ai.application.ver":             1024,
+	"ai.device.id":                   1024,
+	"ai.device.locale":               64,
+	"ai.device.model":                256,
+	"ai.device.oemName":              256,
+	"ai.device.osVersion":            256,
+	"ai.device.type":                 64,
+	"ai.location.ip":                 46,
+	"ai.operation.id":                128,
+	"ai.operation.name":              1024,
+	"ai.operation.parentId":          128,
+	"ai.operation.syntheticSource":   1024,
+	"ai.operation.correlationVector": 64,
+	"ai.session.id":                  64,
+	"ai.session.isFirst":             5,
+	"ai.user.accountId":              1024,
+	"ai.user.id":                     128,
+	"ai.user.authUserId":             1024,
+	"ai.cloud.role":                  256,
+	"ai.cloud.roleInstance":          256,
+	"ai.internal.sdkVersion":         64,
+	"ai.internal.agentVersion":       64,
+	"ai.internal.nodeName":           256,
+}
+
+// Truncates tag values that exceed their maximum supported lengths.  Returns
+// warnings for each affected field.
+func SanitizeTags(tags map[string]string) error {
+	var errs []error
+	for k, v := range tags {
+		if maxlen, ok := tagMaxLengths[k]; ok && len(v) > maxlen {
+			tags[k] = v[:maxlen]
+			errs = append(errs, fmt.Errorf("%s exceeded maximum length of %d", k, maxlen))
+		}
+	}
+	return errors.Join(errs...)
+}
diff --git a/telemetry/internal/appinsights/internal/contracts/envelope.go b/telemetry/internal/appinsights/internal/contracts/envelope.go
new file mode 100644
index 00000000..16d18c39
--- /dev/null
+++ b/telemetry/internal/appinsights/internal/contracts/envelope.go
@@ -0,0 +1,105 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package contracts
+
+import (
+	"errors"
+	"time"
+)
+
+// Data struct to contain both B and C sections.
+type Data struct {
+	BaseType string    `json:"baseType"`
+	BaseData EventData `json:"baseData"`
+}
+
+// EventData represents structured event records that can be grouped
+// and searched by their properties. Event data item also creates a metric of
+// event count by name.
+type EventData struct {
+	// Schema version
+	Ver int `json:"ver"`
+
+	// Event name. Keep it low cardinality to allow proper grouping and useful
+	// metrics.
+	Name string `json:"name"`
+}
+
+// Envelope is the telemetry payload that is sent to the Application Insights.
+type Envelope struct {
+	// Envelope version. For internal use only. By assigning this the default, it
+	// will not be serialized within the payload unless changed to a value other
+	// than #1.
+	Ver int `json:"ver"`
+
+	// Type name of telemetry data item.
+	Name string `json:"name"`
+
+	// Event date time when telemetry item was created. This is the wall clock
+	// time on the client when the event was generated. There is no guarantee that
+	// the client's time is accurate. This field must be formatted in UTC ISO 8601
+	// format, with a trailing 'Z' character, as described publicly on
+	// https://en.wikipedia.org/wiki/ISO_8601#UTC. Note: the number of decimal
+	// seconds digits provided are variable (and unspecified). Consumers should
+	// handle this, i.e. managed code consumers should not use format 'O' for
+	// parsing as it specifies a fixed length. Example:
+	// 2009-06-15T13:45:30.0000000Z.
+	Time time.Time `json:"time"`
+
+	// Sampling rate used in application. This telemetry item represents 1 /
+	// sampleRate actual telemetry items.
+	SampleRate float64 `json:"sampleRate"`
+
+	// Sequence field used to track absolute order of uploaded events.
+	Seq string `json:"seq"`
+
+	// The application's instrumentation key. The key is typically represented as
+	// a GUID, but there are cases when it is not a guid. No code should rely on
+	// iKey being a GUID. Instrumentation key is case insensitive.
+	IKey string `json:"iKey"`
+
+	// Key/value collection of context properties. See ContextTagKeys for
+	// information on available properties.
+	Tags map[string]string `json:"tags,omitempty"`
+
+	// Telemetry data item.
+	Data Data `json:"data"`
+}
+
+// Sanitize truncates string fields that exceed their maximum supported sizes for this
+// object and all objects it references. Returns a warning for each affected
+// field.
+func (data *Envelope) Sanitize() error {
+	var errs []error
+
+	if len(data.Name) > 1024 {
+		data.Name = data.Name[:1024]
+		errs = append(errs, errors.New("Envelope.Name exceeded maximum length of 1024"))
+	}
+
+	if len(data.Seq) > 64 {
+		data.Seq = data.Seq[:64]
+		errs = append(errs, errors.New("Envelope.Seq exceeded maximum length of 64"))
+	}
+
+	if len(data.IKey) > 40 {
+		data.IKey = data.IKey[:40]
+		errs = append(errs, errors.New("Envelope.IKey exceeded maximum length of 40"))
+	}
+
+	if len(data.Data.BaseData.Name) > 512 {
+		data.Data.BaseData.Name = data.Data.BaseData.Name[:512]
+		errs = append(errs, errors.New("EventData.Name exceeded maximum length of 512"))
+	}
+
+	return errors.Join(errs...)
+}
+
+// NewEnvelope creates a new [Envelope] instance with default values set by the schema.
+func NewEnvelope() *Envelope {
+	return &Envelope{
+		Ver:        1,
+		SampleRate: 100.0,
+	}
+}
diff --git a/telemetry/internal/appinsights/internal/contracts/response.go b/telemetry/internal/appinsights/internal/contracts/response.go
new file mode 100644
index 00000000..f7d291fd
--- /dev/null
+++ b/telemetry/internal/appinsights/internal/contracts/response.go
@@ -0,0 +1,32 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package contracts
+
+import "fmt"
+
+const (
+	tooManyRequestsOverExtendedTimeResponse = 439
+)
+
+// BackendResponse represents the response from the Application Insights backend.
+type BackendResponse struct {
+	ItemsReceived int                    `json:"itemsReceived"`
+	ItemsAccepted int                    `json:"itemsAccepted"`
+	Errors        []BackendResponseError `json:"errors"`
+}
+
+func (r *BackendResponse) IsSucess() bool {
+	return r.ItemsReceived == r.ItemsAccepted
+}
+
+// BackendResponseError represents an error in the response from the Application Insights backend.
+type BackendResponseError struct {
+	Index      int    `json:"index"`
+	StatusCode int    `json:"statusCode"`
+	Message    string `json:"message"`
+}
+
+func (r BackendResponseError) Error() string {
+	return fmt.Errorf("index: %d, statusCode: %d, message: %s", r.Index, r.StatusCode, r.Message).Error()
+}
diff --git a/telemetry/internal/appinsights/jsonserializer.go b/telemetry/internal/appinsights/jsonserializer.go
new file mode 100644
index 00000000..02fe54da
--- /dev/null
+++ b/telemetry/internal/appinsights/jsonserializer.go
@@ -0,0 +1,33 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package appinsights
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+)
+
+func serialize(items []batchItem) ([]byte, error) {
+	var result bytes.Buffer
+	encoder := json.NewEncoder(&result)
+
+	var nfail int
+	for _, item := range items {
+		end := result.Len()
+		if err := encoder.Encode(item.item); err != nil {
+			nfail++
+			result.Truncate(end)
+		}
+	}
+	ret := result.Bytes()
+	if nfail > 0 {
+		if nfail == len(items) {
+			ret = nil
+		}
+		return ret, fmt.Errorf("failed to serialize %d items out of %d", nfail, len(items))
+	}
+
+	return ret, nil
+}
diff --git a/telemetry/internal/appinsights/package.go b/telemetry/internal/appinsights/package.go
new file mode 100644
index 00000000..9c2f950a
--- /dev/null
+++ b/telemetry/internal/appinsights/package.go
@@ -0,0 +1,13 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+// Package appinsights provides an interface to submit telemetry to Application Insights,
+// a component of Azure Monitor. This package calls the Classic API.
+// See https://learn.microsoft.com/en-us/azure/azure-monitor/app/api-custom-events-metrics
+package appinsights
+
+const (
+	sdkName         = "go-infra/telemetry"
+	Version         = "v0.0.1"
+	internalVersion = sdkName + ":" + Version
+)
diff --git a/telemetry/internal/appinsights/telemetrycontext.go b/telemetry/internal/appinsights/telemetrycontext.go
new file mode 100644
index 00000000..be989c03
--- /dev/null
+++ b/telemetry/internal/appinsights/telemetrycontext.go
@@ -0,0 +1,49 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package appinsights
+
+import (
+	"strings"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+// telemetryContext encapsulates contextual data common to all telemetry submitted through a
+// TelemetryClient instance such as including instrumentation key, tags, and
+// common properties.
+type telemetryContext struct {
+	// Instrumentation key
+	iKey string
+
+	// Stripped-down instrumentation key used in envelope name
+	nameIKey string
+
+	// Collection of tag data to attach to the telemetry item.
+	Tags contracts.ContextTags
+}
+
+// newTelemetryContext creates a new, empty telemetryContext.
+func newTelemetryContext(ikey string) *telemetryContext {
+	return &telemetryContext{
+		iKey:     ikey,
+		nameIKey: "Microsoft.ApplicationInsights." + strings.Replace(ikey, "-", "", -1) + ".Event",
+		Tags:     make(contracts.ContextTags),
+	}
+}
+
+// Wraps a telemetry item in an envelope with the information found in this
+// context.
+func (context *telemetryContext) envelop(data contracts.EventData) *contracts.Envelope {
+	envelope := contracts.NewEnvelope()
+	envelope.Name = context.nameIKey
+	envelope.Data = contracts.Data{
+		BaseType: "EventData",
+		BaseData: data,
+	}
+	envelope.IKey = context.iKey
+	envelope.Time = time.Now().UTC()
+	envelope.Tags = context.Tags
+	return envelope
+}
diff --git a/telemetry/internal/appinsights/telemetrycontext_test.go b/telemetry/internal/appinsights/telemetrycontext_test.go
new file mode 100644
index 00000000..d3208bd3
--- /dev/null
+++ b/telemetry/internal/appinsights/telemetrycontext_test.go
@@ -0,0 +1,70 @@
+package appinsights
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+const test_ikey = "01234567-0000-89ab-cdef-000000000000"
+
+func TestDefaultTags(t *testing.T) {
+	context := newTelemetryContext(test_ikey)
+	context.Tags["test"] = "OK"
+	context.Tags["no-write"] = "Fail"
+
+	data := contracts.EventData{
+		Name: "Hello world.",
+		Ver:  2,
+	}
+
+	envelope := context.envelop(data)
+
+	if envelope.Tags["test"] != "OK" {
+		t.Error("Default client tags did not propagate to telemetry")
+	}
+
+	if envelope.Tags["no-write"] != "Fail" {
+		t.Error("Default client tag did not propagate to telemetry")
+	}
+}
+
+func TestSanitize(t *testing.T) {
+	name := strings.Repeat("Z", 1024)
+
+	data := contracts.EventData{
+		Name: name,
+		Ver:  2,
+	}
+
+	ctx := newTelemetryContext(test_ikey)
+
+	// We'll be looking for messages with these values:
+	found := map[string]int{
+		"EventData.Name exceeded": 0,
+	}
+
+	// This may break due to hardcoded limits... Check contracts.
+	envelope := ctx.envelop(data)
+
+	err := envelope.Sanitize()
+	msg := err.Error()
+	for k := range found {
+		if strings.Contains(msg, k) {
+			found[k] = found[k] + 1
+		}
+	}
+
+	// Make sure all the warnings were found in the output
+	for k, v := range found {
+		if v != 1 {
+			t.Errorf("Did not find a warning containing %q", k)
+		}
+	}
+
+	evdata := envelope.Data.BaseData
+	if evdata.Name != name[:512] {
+		t.Error("Event name was not truncated")
+	}
+}
diff --git a/telemetry/internal/appinsights/transmitter.go b/telemetry/internal/appinsights/transmitter.go
new file mode 100644
index 00000000..3cca77e3
--- /dev/null
+++ b/telemetry/internal/appinsights/transmitter.go
@@ -0,0 +1,169 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package appinsights
+
+import (
+	"bytes"
+	"cmp"
+	"compress/gzip"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"slices"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+type transmitter interface {
+	transmit(ctx context.Context, items []batchItem) (*transmissionResponse, error)
+}
+
+type httpTransmitter struct {
+	endpoint string
+	client   *http.Client
+}
+
+type transmissionResponse struct {
+	statusCode int
+	retryAfter time.Time
+	response   contracts.BackendResponse
+}
+
+const (
+	successResponse                         = http.StatusOK
+	partialSuccessResponse                  = http.StatusPartialContent
+	requestTimeoutResponse                  = http.StatusRequestTimeout
+	tooManyRequestsResponse                 = http.StatusTooManyRequests
+	tooManyRequestsOverExtendedTimeResponse = 439
+	errorResponse                           = http.StatusInternalServerError
+	serviceUnavailableResponse              = http.StatusServiceUnavailable
+)
+
+func newTransmitter(endpointAddress string, client *http.Client) transmitter {
+	if client == nil {
+		client = http.DefaultClient
+	}
+	return &httpTransmitter{endpointAddress, client}
+}
+
+func (transmitter *httpTransmitter) transmit(ctx context.Context, items []batchItem) (*transmissionResponse, error) {
+	// Serialize the items. It could be that some items can't be serialized,
+	// in which case we will skip them and return an error together with the
+	// transmission result.
+	payload, jsonErr := serialize(items)
+	if jsonErr != nil && payload == nil {
+		return nil, jsonErr
+	}
+
+	// Compress the payload
+	var postBody bytes.Buffer
+	gzipWriter := gzip.NewWriter(&postBody)
+	if _, err := gzipWriter.Write(payload); err != nil {
+		gzipWriter.Close()
+		return nil, fmt.Errorf("failed to compress the payload: %v", err)
+	}
+
+	if err := gzipWriter.Close(); err != nil {
+		return nil, fmt.Errorf("failed to close gzip writer: %v", err)
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, transmitter.endpoint, &postBody)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create request: %v", err)
+	}
+
+	req.Header.Set("Content-Encoding", "gzip")
+	req.Header.Set("Content-Type", "application/x-json-stream")
+	req.Header.Set("Accept-Encoding", "gzip, deflate")
+
+	resp, err := transmitter.client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("failed to transmit telemetry: %v", err)
+	}
+	defer resp.Body.Close()
+
+	result := &transmissionResponse{statusCode: resp.StatusCode}
+	if retryAfterValue := resp.Header.Get("Retry-After"); retryAfterValue != "" {
+		if result.retryAfter, err = time.Parse(time.RFC1123, retryAfterValue); err != nil {
+			return nil, fmt.Errorf("failed to parse Retry-After header: %v", err)
+		}
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&result.response); err != nil {
+		if errors.Is(err, io.EOF) {
+			// Empty response is valid, possibly throttling.
+			return result, nil
+		}
+		return nil, fmt.Errorf("failed to parse response: %v", err)
+	}
+	return result, jsonErr
+}
+
+func (result *transmissionResponse) isSuccess() bool {
+	return result.statusCode == successResponse ||
+		// Partial response but all items accepted
+		(result.statusCode == partialSuccessResponse &&
+			result.response.ItemsReceived == result.response.ItemsAccepted)
+}
+
+func (result *transmissionResponse) isFailure() bool {
+	return result.statusCode != successResponse && result.statusCode != partialSuccessResponse
+}
+
+func (result *transmissionResponse) canRetry() bool {
+	if result.isSuccess() {
+		return false
+	}
+
+	return result.statusCode == partialSuccessResponse ||
+		!result.retryAfter.IsZero() ||
+		(result.statusCode == requestTimeoutResponse ||
+			result.statusCode == serviceUnavailableResponse ||
+			result.statusCode == errorResponse ||
+			result.statusCode == tooManyRequestsResponse ||
+			result.statusCode == tooManyRequestsOverExtendedTimeResponse)
+}
+
+func (result *transmissionResponse) isThrottled() bool {
+	return result.statusCode == tooManyRequestsResponse ||
+		result.statusCode == tooManyRequestsOverExtendedTimeResponse ||
+		!result.retryAfter.IsZero()
+}
+
+func canRetryBackendError(berror contracts.BackendResponseError) bool {
+	return berror.StatusCode == requestTimeoutResponse ||
+		berror.StatusCode == serviceUnavailableResponse ||
+		berror.StatusCode == errorResponse ||
+		berror.StatusCode == tooManyRequestsResponse ||
+		berror.StatusCode == tooManyRequestsOverExtendedTimeResponse
+}
+
+func (result *transmissionResponse) getRetryItems(items []batchItem) []batchItem {
+	if result.statusCode == partialSuccessResponse {
+		// Make sure errors are ordered by index
+		slices.SortFunc(result.response.Errors, func(a, b contracts.BackendResponseError) int {
+			return cmp.Compare(a.Index, b.Index)
+		})
+
+		resultItems := make([]batchItem, 0, len(result.response.Errors))
+		// Find each retryable error
+		for _, responseResult := range result.response.Errors {
+			if !canRetryBackendError(responseResult) {
+				continue
+			}
+			if responseResult.Index >= len(items) {
+				continue
+			}
+			resultItems = append(resultItems, items[responseResult.Index])
+		}
+
+		return resultItems
+	} else if result.canRetry() {
+		return items
+	}
+	return nil
+}


Reviews:
user: login: dagood
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T19:47:38Z
user: login: dagood
body: Nice. I didn't poke at the test code much.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T17:37:58Z
user: login: qmuntal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T10:20:18Z
user: login: qmuntal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T10:21:07Z
user: login: qmuntal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T10:22:25Z
user: login: qmuntal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T10:42:12Z
user: login: qmuntal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T10:44:37Z
user: login: qmuntal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T10:45:28Z
user: login: qmuntal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T14:16:00Z
user: login: gdams
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-08T15:31:10Z
user: login: dagood
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T22:41:03Z
user: login: qmuntal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T07:08:07Z
user: login: qmuntal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T07:16:41Z
user: login: qmuntal
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T07:17:30Z
user: login: dagood
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-14T17:42:37Z

Review comments:
diff_hunk: @@ -0,0 +1,174 @@
+package appinsights
+
+import (
+	"cmp"
+	"context"
+	"log"
+	"net/http"
+	"runtime"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+// Client is the main entry point for sending telemetry to Application Insights.
+// Changing its properties after a telemetry item is created will have no effect.
+type Client struct {
+	// The instrumentation key used to identify the application.
+	// This key is required and must be set before sending any telemetry.
+	InstrumentationKey string
+
+	// The endpoint URL to which telemetry will be sent.
+	// If empty, it defaults to https://dc.services.visualstudio.com/v2/track.
+	Endpoint string
+
+	// Maximum number of telemetry items that can be submitted in each
+	// request. If this many items are buffered, the buffer will be
+	// flushed before MaxBatchInterval expires.
+	// If zero, it defaults to 1024.
+	MaxBatchSize int
+
+	// Maximum time to wait before sending a batch of telemetry.
+	// If zero, it defaults to 10 seconds.
+	MaxBatchInterval time.Duration
+
+	// Customized http client.
+	// If nil, it defaults to http.DefaultClient.
+	HttpClient *http.Client
path: telemetry/internal/appinsights/client.go
user: login: dagood
body: Nit: `HTTPClient`
created_at: 2025-05-05T18:45:38Z
author_association: MEMBER
diff_hunk: @@ -85,155 +81,115 @@ func (transmitter *httpTransmitter) Transmit(payload []byte, items telemetryBuff
 
 	resp, err := transmitter.client.Do(req)
 	if err != nil {
-		log.Printf("Failed to transmit telemetry: %s", err.Error())
-		return nil, err
+		return nil, fmt.Errorf("failed to transmit telemetry: %v", err)
 	}
-
 	defer resp.Body.Close()
 
-	body, err := io.ReadAll(resp.Body)
-	if err != nil {
-		log.Printf("Failed to read response from server: %s", err.Error())
-		return nil, err
-	}
-
-	duration := time.Since(startTime)
-
 	result := &transmissionResult{statusCode: resp.StatusCode}
-
 	// Grab Retry-After header
-	if retryAfterValue, ok := resp.Header[http.CanonicalHeaderKey("Retry-After")]; ok && len(retryAfterValue) == 1 {
-		if retryAfterTime, err := time.Parse(time.RFC1123, retryAfterValue[0]); err == nil {
-			result.retryAfter = &retryAfterTime
+	if retryAfterValue := resp.Header.Get("Retry-After"); retryAfterValue != "" {
+		if result.retryAfter, err = time.Parse(time.RFC1123, retryAfterValue); err != nil {
+			return nil, fmt.Errorf("failed to parse Retry-After header: %v", err)
 		}
 	}
 
 	// Parse body, if possible
-	response := &backendResponse{}
-	if err := json.Unmarshal(body, &response); err == nil {
-		result.response = response
-	}
-
-	// Write diagnostics
-	log.Printf("Telemetry transmitted in %s", duration)
-	log.Printf("Response: %d", result.statusCode)
-	if result.response != nil {
-		log.Printf("Items accepted/received: %d/%d", result.response.ItemsAccepted, result.response.ItemsReceived)
-		if len(result.response.Errors) > 0 {
-			log.Printf("Errors:")
-			for _, err := range result.response.Errors {
-				if err.Index < len(items) {
-					log.Printf("#%d - %d %s", err.Index, err.StatusCode, err.Message)
-					log.Printf("Telemetry item:\n\t%s", string(items[err.Index:err.Index+1].serialize()))
-				}
-			}
+	if err := json.NewDecoder(resp.Body).Decode(&result.response); err != nil {
+		if err == io.EOF {
path: telemetry/internal/appinsights/transmitter.go
user: login: dagood
body: For the sake of modern convention, maybe:

```suggestion
		if errors.Is(err, io.EOF) {
```
created_at: 2025-05-06T15:54:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,3 @@
+# telemetry
+
+This directory contains the telemetry code for the Microsoft build of Go.
path: telemetry/README.md
user: login: dagood
body: ```suggestion
This directory contains the telemetry transmission code for the Microsoft build of Go.
```

(I think "telemetry code" includes a lot of the stuff that we're sharing with upstream, and the flags unique to the Microsoft build of Go that I imagine we'll maintain in there.)
created_at: 2025-05-06T15:58:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,11 @@
+# appinsights
+
+This package is a trimmed down version of https://github.com/microsoft/ApplicationInsights-Go.
+It is tailored for the use of the [Microsoft build of Go](https://github.com/microsoft/go).
+
+These are the changes made to the original package:
+
+- Remove all external dependencies.
+- Remove all telemetry types except for `Event`.
path: telemetry/internal/appinsights/README.md
user: login: dagood
body: ```suggestion
- Remove all telemetry types except for `Event`.
- Remove most unused features, including `Event` features.
```

(Arguably this is part of simplifying the API, though.)
created_at: 2025-05-06T16:00:13Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,184 @@
+package appinsights
+
+import (
+	"cmp"
+	"context"
+	"log"
+	"maps"
+	"net/http"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+// Client is the main entry point for sending telemetry to Application Insights.
+// Changing its properties after a telemetry item is created will have no effect.
+type Client struct {
+	// The instrumentation key used to identify the application.
+	// This key is required and must be set before sending any telemetry.
+	InstrumentationKey string
+
+	// The endpoint URL to which telemetry will be sent.
+	// If empty, it defaults to https://dc.services.visualstudio.com/v2/track.
+	Endpoint string
+
+	// Maximum number of telemetry items that can be submitted in each
+	// request. If this many items are buffered, the buffer will be
+	// flushed before MaxBatchInterval expires.
+	// If zero, it defaults to 1024.
+	MaxBatchSize int
+
+	// Maximum time to wait before sending a batch of telemetry.
+	// If zero, it defaults to 10 seconds.
+	MaxBatchInterval time.Duration
+
+	// Customized http client.
+	// If nil, it defaults to http.DefaultClient.
+	HTTPClient *http.Client
+
+	// Tags to be sent with every telemetry item.
+	// If nil, no additional tags will be sent.
+	Tags map[string]string
+
+	// ErrorLog specifies an optional logger for errors
+	// that occur when attempting to upload telemetry.
+	// If nil, logging is done via the log package's standard logger.
+	ErrorLog *log.Logger
+
+	channel  *inMemoryChannel
+	context  *telemetryContext
+	disabled bool
+
+	initialized atomic.Bool
+	initOnce    sync.Once
+}
+
+// init initializes the client.
+// It is safe to call this method multiple times concurrently.
+func (c *Client) init() {
+	c.initOnce.Do(func() {
+		if c.InstrumentationKey == "" {
+			panic("instrumentation key is required")
+		}
+		endpoint := cmp.Or(c.Endpoint, "https://dc.services.visualstudio.com/v2/track")
+		batchSize := cmp.Or(c.MaxBatchSize, 1024)
+		batchInterval := cmp.Or(c.MaxBatchInterval, 10*time.Second)
+		httpClient := cmp.Or(c.HTTPClient, http.DefaultClient)
+		c.channel = newInMemoryChannel(endpoint, batchSize, batchInterval, httpClient)
+		c.context = setupContext(c.InstrumentationKey, c.Tags)
+		if err := contracts.SanitizeTags(c.context.Tags); err != nil {
+			c.ErrorLog.Printf("Warning sanitizing tags: %v", err)
+		}
+		c.initialized.Store(true)
+	})
+}
+
+func setupContext(instrumentationKey string, tags map[string]string) *telemetryContext {
+	context := newTelemetryContext(instrumentationKey)
+	context.Tags["ai.internal.sdkVersion"] = sdkName + ":" + version
+	maps.Copy(context.Tags, tags)
+	return context
+}
+
+// Enabled returns true if this client is enabled and will accept telemetry.
+func (c *Client) Enabled() bool {
+	return !c.disabled
+}
+
+// SetEnabled enables or disables the client. When disabled, telemetry is
+// silently swallowed by the client. Defaults to enabled.
+func (c *Client) SetEnabled(enabled bool) {
+	c.disabled = !enabled
+}
+
+// NewEvent creates a new event with the specified name.
+func (c *Client) NewEvent(name string) *Event {
+	return &Event{
+		name:   name,
+		client: c,
+	}
+}
+
+// TrackEvent logs a user action with the specified name.
+func (c *Client) TrackEvent(name string) {
+	c.NewEvent(name).Inc()
+}
+
+// Forces the current queue to be sent.
+func (c *Client) Flush() {
+	if !c.initialized.Load() {
+		return
+	}
+	c.channel.flush()
+}
+
+// Close flushes and tears down the submission goroutine and closes internal channels.
+// Waits until all pending telemetry items have been submitted.
+func (c *Client) Close(ctx context.Context) error {
+	if !c.initialized.Load() {
+		return nil
+	}
+	var d time.Duration
+	if timeout, ok := ctx.Deadline(); ok {
+		d = time.Until(timeout)
+		if d == 0 {
+			// Don't confuse the user with a zero duration.
+			d = -1
+		}
+	}
+	select {
+	case <-ctx.Done():
+		return ctx.Err()
+	case <-c.channel.close(d):
+		// TODO: check for errors.
+		return nil
+	}
+}
+
+// Tears down the submission goroutines, closes internal channels.
path: telemetry/internal/appinsights/client.go
user: login: dagood
body: ```suggestion
// Stop tears down the submission goroutines, closes internal channels.
```

There are other APIs with similar first words missing too (spotted `Flush`, `track`)
created_at: 2025-05-06T16:59:26Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,184 @@
+package appinsights
+
+import (
+	"cmp"
+	"context"
+	"log"
+	"maps"
+	"net/http"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+// Client is the main entry point for sending telemetry to Application Insights.
+// Changing its properties after a telemetry item is created will have no effect.
+type Client struct {
+	// The instrumentation key used to identify the application.
+	// This key is required and must be set before sending any telemetry.
+	InstrumentationKey string
+
+	// The endpoint URL to which telemetry will be sent.
+	// If empty, it defaults to https://dc.services.visualstudio.com/v2/track.
+	Endpoint string
+
+	// Maximum number of telemetry items that can be submitted in each
+	// request. If this many items are buffered, the buffer will be
+	// flushed before MaxBatchInterval expires.
+	// If zero, it defaults to 1024.
+	MaxBatchSize int
+
+	// Maximum time to wait before sending a batch of telemetry.
+	// If zero, it defaults to 10 seconds.
+	MaxBatchInterval time.Duration
+
+	// Customized http client.
+	// If nil, it defaults to http.DefaultClient.
+	HTTPClient *http.Client
+
+	// Tags to be sent with every telemetry item.
+	// If nil, no additional tags will be sent.
+	Tags map[string]string
+
+	// ErrorLog specifies an optional logger for errors
+	// that occur when attempting to upload telemetry.
+	// If nil, logging is done via the log package's standard logger.
+	ErrorLog *log.Logger
+
+	channel  *inMemoryChannel
+	context  *telemetryContext
+	disabled bool
+
+	initialized atomic.Bool
+	initOnce    sync.Once
+}
+
+// init initializes the client.
+// It is safe to call this method multiple times concurrently.
+func (c *Client) init() {
+	c.initOnce.Do(func() {
+		if c.InstrumentationKey == "" {
+			panic("instrumentation key is required")
+		}
+		endpoint := cmp.Or(c.Endpoint, "https://dc.services.visualstudio.com/v2/track")
+		batchSize := cmp.Or(c.MaxBatchSize, 1024)
+		batchInterval := cmp.Or(c.MaxBatchInterval, 10*time.Second)
+		httpClient := cmp.Or(c.HTTPClient, http.DefaultClient)
+		c.channel = newInMemoryChannel(endpoint, batchSize, batchInterval, httpClient)
+		c.context = setupContext(c.InstrumentationKey, c.Tags)
+		if err := contracts.SanitizeTags(c.context.Tags); err != nil {
+			c.ErrorLog.Printf("Warning sanitizing tags: %v", err)
+		}
+		c.initialized.Store(true)
+	})
+}
+
+func setupContext(instrumentationKey string, tags map[string]string) *telemetryContext {
+	context := newTelemetryContext(instrumentationKey)
+	context.Tags["ai.internal.sdkVersion"] = sdkName + ":" + version
+	maps.Copy(context.Tags, tags)
+	return context
+}
+
+// Enabled returns true if this client is enabled and will accept telemetry.
+func (c *Client) Enabled() bool {
+	return !c.disabled
+}
+
+// SetEnabled enables or disables the client. When disabled, telemetry is
+// silently swallowed by the client. Defaults to enabled.
+func (c *Client) SetEnabled(enabled bool) {
+	c.disabled = !enabled
+}
+
+// NewEvent creates a new event with the specified name.
+func (c *Client) NewEvent(name string) *Event {
+	return &Event{
+		name:   name,
+		client: c,
+	}
+}
+
+// TrackEvent logs a user action with the specified name.
+func (c *Client) TrackEvent(name string) {
+	c.NewEvent(name).Inc()
+}
+
+// Forces the current queue to be sent.
+func (c *Client) Flush() {
+	if !c.initialized.Load() {
+		return
+	}
+	c.channel.flush()
+}
+
+// Close flushes and tears down the submission goroutine and closes internal channels.
+// Waits until all pending telemetry items have been submitted.
+func (c *Client) Close(ctx context.Context) error {
+	if !c.initialized.Load() {
+		return nil
+	}
+	var d time.Duration
+	if timeout, ok := ctx.Deadline(); ok {
+		d = time.Until(timeout)
+		if d == 0 {
+			// Don't confuse the user with a zero duration.
+			d = -1
+		}
+	}
+	select {
+	case <-ctx.Done():
+		return ctx.Err()
+	case <-c.channel.close(d):
+		// TODO: check for errors.
+		return nil
+	}
+}
+
+// Tears down the submission goroutines, closes internal channels.
+// Any telemetry waiting to be sent is discarded.
+// This is a more abrupt version of [Client.Close].
+func (c *Client) Stop() {
+	if !c.initialized.Load() {
+		return
+	}
+	c.channel.stop()
+}
+
+// Submits the specified telemetry item.
+func (c *Client) track(data contracts.EventData) {
+	if !c.disabled {
+		c.init()
+		ev := c.context.envelop(data)
+		if err := ev.Sanitize(); err != nil {
+			c.ErrorLog.Printf("Warning sanitizing telemetry item: %v", err)
+		}
+		c.channel.send(c.context.envelop(data))
+	}
+}
+
+// Event represents an event to be tracked.
+type Event struct {
+	name   string
+	client *Client
+}
+
+// Inc adds 1 to the counter.
+func (e *Event) Inc() {
+	e.Add(1)
+}
+
+// Add adds n to the counter. n cannot be negative, as counts cannot decrease.
+func (e *Event) Add(n uint64) {
+	if n == 0 {
+		return
+	}
+	for range n {
+		e.client.track(contracts.EventData{
path: telemetry/internal/appinsights/client.go
user: login: dagood
body: It feels like there should be some practical limit here because of the wasteful-seeming way they're queued up to be transmitted... maybe at least some caution in the doc comment.
created_at: 2025-05-06T17:01:43Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,102 @@
+//go:build goexperiment.synctest
+
+package appinsights
+
+import (
+	"bytes"
+	"compress/gzip"
+	"context"
+	"io"
+	"log"
+	"net/http"
+	"testing"
+	"testing/synctest"
+)
+
+type fakeTransport struct {
+	code int
+	body []byte
+	err  error
+}
+
+func (t fakeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+	return &http.Response{
+		StatusCode: t.code,
+		Body:       io.NopCloser(bytes.NewBuffer(t.body)),
+	}, t.err
+}
+
+func BenchmarkClientBurstPerformance(b *testing.B) {
+	client := &Client{
+		InstrumentationKey: test_ikey,
+		ErrorLog:           log.New(io.Discard, "", 0),
+		HTTPClient: &http.Client{
+			Transport: fakeTransport{
+				code: http.StatusOK,
+				body: []byte(`{"itemsReceived":4, "itemsAccepted":4, "errors":[]}`),
+			},
+		},
+	}
+
+	for b.Loop() {
+		client.TrackEvent("A message")
+	}
+
+	client.Close(context.Background())
+}
+
+func TestEndToEnd(t *testing.T) {
+	synctest.Run(func() {
+		xmit, server := newTestClientServer(t)
+		defer server.Close()
+
+		// Set up server response
+		server.responseData = []byte(`{"itemsReceived":4, "itemsAccepted":4, "errors":[]}`)
+		server.responseHeaders["Content-type"] = "application/json"
+
+		client := &Client{InstrumentationKey: test_ikey, Endpoint: xmit.(*httpTransmitter).endpoint, HTTPClient: xmit.(*httpTransmitter).client}
+		defer client.Close(context.Background())
+
+		// Track directly off the client
+		client.TrackEvent("client-event")
+
+		// NOTE: A lot of this is covered elsewhere, so we won't duplicate
+		// *too* much.
+
+		// Wait for automatic transmit -- get the request
+		slowTick(11)
+		req := server.waitForRequest(t)
+
+		// GZIP magic number
+		if len(req.body) < 2 || req.body[0] != 0x1f || req.body[1] != 0x8b {
+			t.Fatal("Missing gzip magic number")
+		}
+
+		// Decompress
+		reader, err := gzip.NewReader(bytes.NewReader(req.body))
+		if err != nil {
+			t.Fatalf("Coudln't create gzip reader: %s", err.Error())
+		}
+
+		// Read payload
+		body, err := io.ReadAll(reader)
+		reader.Close()
+		if err != nil {
+			t.Fatalf("Couldn't read compressed data: %s", err.Error())
+		}
path: telemetry/internal/appinsights/client_test.go
user: login: dagood
body: Seems like a decent chunk of this could be shared with `telemetry/internal/appinsights/transmitter_test.go`, up to the GZIP magic number?
created_at: 2025-05-06T17:03:23Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,184 @@
+package appinsights
path: telemetry/internal/appinsights/client.go
user: login: dagood
body: Copyright headers?
created_at: 2025-05-06T17:04:05Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,453 @@
+package appinsights
+
+import (
+	"log"
+	"net/http"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+var (
+	submit_retries = []time.Duration{time.Duration(10 * time.Second), time.Duration(30 * time.Second), time.Duration(60 * time.Second)}
path: telemetry/internal/appinsights/inmemorychannel.go
user: login: dagood
body: ```suggestion
	submitRetries = []time.Duration{time.Duration(10 * time.Second), time.Duration(30 * time.Second), time.Duration(60 * time.Second)}
```
created_at: 2025-05-06T17:04:36Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,453 @@
+package appinsights
+
+import (
+	"log"
+	"net/http"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+var (
+	submit_retries = []time.Duration{time.Duration(10 * time.Second), time.Duration(30 * time.Second), time.Duration(60 * time.Second)}
+)
+
+// inMemoryChannel stores events exclusively in memory.
+// Presently the only telemetry channel implementation available.
+type inMemoryChannel struct {
+	endpointAddr  string
+	collectChan   chan *contracts.Envelope
+	controlChan   chan *inMemoryChannelControl
+	batchSize     int
+	batchInterval time.Duration
+	waitgroup     sync.WaitGroup
+	throttle      *throttleManager
+	transmitter   transmitter
+	errorLog      *log.Logger
+
+	closed atomic.Bool
+}
+
+type inMemoryChannelControl struct {
+	// If true, flush the buffer.
+	flush bool
+
+	// If true, stop listening on the channel.  (Flush is required if any events are to be sent)
+	stop bool
+
+	// If stopping and flushing, this specifies whether to retry submissions on error.
+	retry bool
+
+	// If retrying, what is the max time to wait before finishing up?
+	timeout time.Duration
+
+	// If specified, a message will be sent on this channel when all pending telemetry items have been submitted
+	callback chan struct{}
+}
+
+// newInMemoryChannel creates an inMemoryChannel instance and starts a background submission goroutine.
path: telemetry/internal/appinsights/inmemorychannel.go
user: login: dagood
body: Maybe mention what's necessary to make sure you don't leak a goroutine after getting a result from this func? (At least, my interpretation is that it's necessary to close/stop it.)
created_at: 2025-05-06T17:07:10Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,453 @@
+package appinsights
+
+import (
+	"log"
+	"net/http"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+var (
+	submit_retries = []time.Duration{time.Duration(10 * time.Second), time.Duration(30 * time.Second), time.Duration(60 * time.Second)}
+)
+
+// inMemoryChannel stores events exclusively in memory.
+// Presently the only telemetry channel implementation available.
+type inMemoryChannel struct {
+	endpointAddr  string
+	collectChan   chan *contracts.Envelope
+	controlChan   chan *inMemoryChannelControl
+	batchSize     int
+	batchInterval time.Duration
+	waitgroup     sync.WaitGroup
+	throttle      *throttleManager
+	transmitter   transmitter
+	errorLog      *log.Logger
+
+	closed atomic.Bool
+}
+
+type inMemoryChannelControl struct {
+	// If true, flush the buffer.
+	flush bool
+
+	// If true, stop listening on the channel.  (Flush is required if any events are to be sent)
+	stop bool
+
+	// If stopping and flushing, this specifies whether to retry submissions on error.
+	retry bool
+
+	// If retrying, what is the max time to wait before finishing up?
+	timeout time.Duration
+
+	// If specified, a message will be sent on this channel when all pending telemetry items have been submitted
+	callback chan struct{}
+}
+
+// newInMemoryChannel creates an inMemoryChannel instance and starts a background submission goroutine.
+func newInMemoryChannel(endpointUrl string, batchSize int, batchInterval time.Duration, httpClient *http.Client) *inMemoryChannel {
+	channel := &inMemoryChannel{
+		endpointAddr:  endpointUrl,
+		collectChan:   make(chan *contracts.Envelope),
+		controlChan:   make(chan *inMemoryChannelControl),
+		batchSize:     batchSize,
+		batchInterval: batchInterval,
+		throttle:      newThrottleManager(),
+		transmitter:   newTransmitter(endpointUrl, httpClient),
+	}
+
+	go channel.acceptLoop()
+
+	return channel
+}
+
+func (channel *inMemoryChannel) log(v ...any) {
+	if channel.errorLog != nil {
+		channel.errorLog.Print(v...)
+	} else {
+		log.Print(v...)
+	}
+}
+
+func (channel *inMemoryChannel) logf(format string, args ...any) {
+	if channel.errorLog != nil {
+		channel.errorLog.Printf(format, args...)
+	} else {
+		log.Printf(format, args...)
+	}
+}
+
+// Queues a single telemetry item
+func (channel *inMemoryChannel) send(item *contracts.Envelope) {
+	if item != nil && !channel.closed.Load() {
+		channel.collectChan <- item
+	}
+}
+
+// Forces the current queue to be sent
+func (channel *inMemoryChannel) flush() {
+	if channel.closed.Load() {
+		return
+	}
+	channel.controlChan <- &inMemoryChannelControl{
+		flush: true,
+	}
+}
+
+// Tears down the submission goroutines, closes internal channels.  Any
+// telemetry waiting to be sent is discarded.  Further calls to Send() have
+// undefined behavior.  This is a more abrupt version of Close().
+func (channel *inMemoryChannel) stop() {
+	if channel.closed.Load() {
+		return
+	}
+	channel.controlChan <- &inMemoryChannelControl{
+		stop: true,
+	}
+}
+
+// Returns true if this channel has been throttled by the data collector.
+func (channel *inMemoryChannel) isThrottled() bool {
+	return channel.throttle != nil && channel.throttle.isThrottled()
+}
+
+// Flushes and tears down the submission goroutine and closes internal
+// channels.  Returns a channel that is closed when all pending telemetry
+// items have been submitted and it is safe to shut down without losing
+// telemetry.
+//
+// If retryTimeout is specified and non-zero, then failed submissions will
+// be retried until one succeeds or the timeout expires, whichever occurs
+// first.  A retryTimeout of zero indicates that failed submissions will be
+// retried as usual.  An omitted retryTimeout indicates that submissions
+// should not be retried if they fail.
+//
+// Note that the returned channel may not be closed before retryTimeout even
+// if it is specified.  This is because retryTimeout only applies to the
+// latest telemetry buffer.  This may be typical for applications that
+// submit a large amount of telemetry or are prone to being throttled.  When
+// exiting, you should select on the result channel and your own timer to
+// avoid long delays.
+func (channel *inMemoryChannel) close(timeout time.Duration) <-chan struct{} {
+	if channel.closed.Load() {
+		return nil
+	}
+	callback := make(chan struct{})
+
+	ctl := &inMemoryChannelControl{
+		stop:     true,
+		flush:    true,
+		retry:    false,
+		callback: callback,
+	}
+
+	if timeout != 0 {
+		ctl.retry = true
+		ctl.timeout = timeout
+	}
+
+	channel.controlChan <- ctl
+
+	return callback
+}
+
+func (channel *inMemoryChannel) acceptLoop() {
+	channelState := newInMemoryChannelState(channel)
+
+	for !channelState.stopping {
+		channelState.start()
+	}
+
+	channelState.stop()
+}
+
+// Data shared between parts of a channel
+type inMemoryChannelState struct {
+	channel      *inMemoryChannel
+	stopping     bool
+	buffer       []*contracts.Envelope
+	retry        bool
+	retryTimeout time.Duration
+	callback     chan struct{}
+	timer        *time.Timer
+}
+
+func newInMemoryChannelState(channel *inMemoryChannel) *inMemoryChannelState {
+	timer := time.NewTimer(time.Hour)
+	timer.Stop()
+	return &inMemoryChannelState{
+		channel:  channel,
+		buffer:   make([]*contracts.Envelope, 0, 16),
+		stopping: false,
+		timer:    timer,
+	}
+}
+
+// Part of channel accept loop: Initialize buffer and accept first message, handle controls.
+func (state *inMemoryChannelState) start() bool {
+	if len(state.buffer) > 16 {
+		// Start out with the size of the previous buffer
+		state.buffer = make([]*contracts.Envelope, 0, cap(state.buffer))
+	} else if len(state.buffer) > 0 {
+		// Start out with at least 16 slots
+		state.buffer = make([]*contracts.Envelope, 0, 16)
+	}
+
+	// Wait for an event
+	select {
+	case event := <-state.channel.collectChan:
+		if event == nil {
+			// Channel closed?  Not intercepted by Send()?
+			panic("Received nil event")
+		}
+
+		state.buffer = append(state.buffer, event)
+
+	case ctl := <-state.channel.controlChan:
+		// The buffer is empty, so there would be no point in flushing
+		state.channel.signalWhenDone(ctl.callback)
+
+		if ctl.stop {
+			state.stopping = true
+			return false
+		}
+	}
+
+	if len(state.buffer) == 0 {
+		return true
+	}
+
+	return state.waitToSend()
+}
+
+// Part of channel accept loop: Wait for buffer to fill, timeout to expire, or flush
+func (state *inMemoryChannelState) waitToSend() bool {
+	// Things that are used by the sender if we receive a control message
+	state.retryTimeout = 0
+	state.retry = true
+	state.callback = nil
+
+	// Delay until timeout passes or buffer fills up
+	state.timer.Reset(state.channel.batchInterval)
+	for {
+		if len(state.buffer) >= state.channel.batchSize {
+			state.timer.Stop()
+			return state.send()
+		}
+
+		select {
+		case event := <-state.channel.collectChan:
+			if event == nil {
+				// Channel closed?  Not intercepted by Send()?
+				panic("Received nil event")
+			}
+
+			state.buffer = append(state.buffer, event)
+
+		case ctl := <-state.channel.controlChan:
+			if ctl.stop {
+				state.stopping = true
+				state.retry = ctl.retry
+				if !ctl.flush {
+					// No flush? Just exit.
+					state.channel.signalWhenDone(ctl.callback)
+					return false
+				}
+			}
+
+			if ctl.flush {
+				state.timer.Stop()
+				state.retryTimeout = ctl.timeout
+				state.callback = ctl.callback
+				return state.send()
+			}
+
+		case <-state.timer.C:
+			// Timeout expired
+			return state.send()
+		}
+	}
+}
+
+// Part of channel accept loop: Check and wait on throttle, submit pending telemetry
+func (state *inMemoryChannelState) send() bool {
+	// Hold up transmission if we're being throttled
+	if !state.stopping && state.channel.throttle.isThrottled() {
+		if !state.waitThrottle() {
+			// Stopped
+			return false
+		}
+	}
+
+	// Send
+	if len(state.buffer) > 0 {
+		state.channel.waitgroup.Add(1)
+
+		// If we have a callback, wait on the waitgroup now that it's
+		// incremented.
+		state.channel.signalWhenDone(state.callback)
+
+		go func(buffer []*contracts.Envelope, retry bool, retryTimeout time.Duration) {
+			defer state.channel.waitgroup.Done()
+			state.channel.transmitRetry(buffer, retry, retryTimeout)
+		}(state.buffer, state.retry, state.retryTimeout)
path: telemetry/internal/appinsights/inmemorychannel.go
user: login: dagood
body: Personally I find capture more concise/readable/maintainable, although I do see the explicit func args pattern often in Go code. Curious if there is a deeper reason for it that I'm missing vs.:

```suggestion
		buffer := state.buffer
		retry := state.retry
		retryTimeout := state.retryTimeout
		go func() {
			defer state.channel.waitgroup.Done()
			state.channel.transmitRetry(buffer, retry, retryTimeout)
		}()
```

(I can't say for sure I follow why these are all copied, but I have an intuition about why it *could* be important...)
created_at: 2025-05-06T17:16:17Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,453 @@
+package appinsights
+
+import (
+	"log"
+	"net/http"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+var (
+	submit_retries = []time.Duration{time.Duration(10 * time.Second), time.Duration(30 * time.Second), time.Duration(60 * time.Second)}
+)
+
+// inMemoryChannel stores events exclusively in memory.
+// Presently the only telemetry channel implementation available.
+type inMemoryChannel struct {
+	endpointAddr  string
+	collectChan   chan *contracts.Envelope
+	controlChan   chan *inMemoryChannelControl
+	batchSize     int
+	batchInterval time.Duration
+	waitgroup     sync.WaitGroup
+	throttle      *throttleManager
+	transmitter   transmitter
+	errorLog      *log.Logger
+
+	closed atomic.Bool
+}
+
+type inMemoryChannelControl struct {
+	// If true, flush the buffer.
+	flush bool
+
+	// If true, stop listening on the channel.  (Flush is required if any events are to be sent)
+	stop bool
+
+	// If stopping and flushing, this specifies whether to retry submissions on error.
+	retry bool
+
+	// If retrying, what is the max time to wait before finishing up?
+	timeout time.Duration
+
+	// If specified, a message will be sent on this channel when all pending telemetry items have been submitted
+	callback chan struct{}
+}
+
+// newInMemoryChannel creates an inMemoryChannel instance and starts a background submission goroutine.
+func newInMemoryChannel(endpointUrl string, batchSize int, batchInterval time.Duration, httpClient *http.Client) *inMemoryChannel {
+	channel := &inMemoryChannel{
+		endpointAddr:  endpointUrl,
+		collectChan:   make(chan *contracts.Envelope),
+		controlChan:   make(chan *inMemoryChannelControl),
+		batchSize:     batchSize,
+		batchInterval: batchInterval,
+		throttle:      newThrottleManager(),
+		transmitter:   newTransmitter(endpointUrl, httpClient),
+	}
+
+	go channel.acceptLoop()
+
+	return channel
+}
+
+func (channel *inMemoryChannel) log(v ...any) {
+	if channel.errorLog != nil {
+		channel.errorLog.Print(v...)
+	} else {
+		log.Print(v...)
+	}
+}
+
+func (channel *inMemoryChannel) logf(format string, args ...any) {
+	if channel.errorLog != nil {
+		channel.errorLog.Printf(format, args...)
+	} else {
+		log.Printf(format, args...)
+	}
+}
+
+// Queues a single telemetry item
+func (channel *inMemoryChannel) send(item *contracts.Envelope) {
+	if item != nil && !channel.closed.Load() {
+		channel.collectChan <- item
+	}
+}
+
+// Forces the current queue to be sent
+func (channel *inMemoryChannel) flush() {
+	if channel.closed.Load() {
+		return
+	}
+	channel.controlChan <- &inMemoryChannelControl{
+		flush: true,
+	}
+}
+
+// Tears down the submission goroutines, closes internal channels.  Any
+// telemetry waiting to be sent is discarded.  Further calls to Send() have
+// undefined behavior.  This is a more abrupt version of Close().
+func (channel *inMemoryChannel) stop() {
+	if channel.closed.Load() {
+		return
+	}
+	channel.controlChan <- &inMemoryChannelControl{
+		stop: true,
+	}
+}
+
+// Returns true if this channel has been throttled by the data collector.
+func (channel *inMemoryChannel) isThrottled() bool {
+	return channel.throttle != nil && channel.throttle.isThrottled()
+}
+
+// Flushes and tears down the submission goroutine and closes internal
+// channels.  Returns a channel that is closed when all pending telemetry
+// items have been submitted and it is safe to shut down without losing
+// telemetry.
+//
+// If retryTimeout is specified and non-zero, then failed submissions will
+// be retried until one succeeds or the timeout expires, whichever occurs
+// first.  A retryTimeout of zero indicates that failed submissions will be
+// retried as usual.  An omitted retryTimeout indicates that submissions
+// should not be retried if they fail.
+//
+// Note that the returned channel may not be closed before retryTimeout even
+// if it is specified.  This is because retryTimeout only applies to the
+// latest telemetry buffer.  This may be typical for applications that
+// submit a large amount of telemetry or are prone to being throttled.  When
+// exiting, you should select on the result channel and your own timer to
+// avoid long delays.
+func (channel *inMemoryChannel) close(timeout time.Duration) <-chan struct{} {
+	if channel.closed.Load() {
+		return nil
+	}
+	callback := make(chan struct{})
+
+	ctl := &inMemoryChannelControl{
+		stop:     true,
+		flush:    true,
+		retry:    false,
+		callback: callback,
+	}
+
+	if timeout != 0 {
+		ctl.retry = true
+		ctl.timeout = timeout
+	}
+
+	channel.controlChan <- ctl
+
+	return callback
+}
+
+func (channel *inMemoryChannel) acceptLoop() {
+	channelState := newInMemoryChannelState(channel)
+
+	for !channelState.stopping {
+		channelState.start()
+	}
+
+	channelState.stop()
+}
+
+// Data shared between parts of a channel
+type inMemoryChannelState struct {
+	channel      *inMemoryChannel
+	stopping     bool
+	buffer       []*contracts.Envelope
+	retry        bool
+	retryTimeout time.Duration
+	callback     chan struct{}
+	timer        *time.Timer
+}
+
+func newInMemoryChannelState(channel *inMemoryChannel) *inMemoryChannelState {
+	timer := time.NewTimer(time.Hour)
+	timer.Stop()
+	return &inMemoryChannelState{
+		channel:  channel,
+		buffer:   make([]*contracts.Envelope, 0, 16),
+		stopping: false,
+		timer:    timer,
+	}
+}
+
+// Part of channel accept loop: Initialize buffer and accept first message, handle controls.
+func (state *inMemoryChannelState) start() bool {
+	if len(state.buffer) > 16 {
+		// Start out with the size of the previous buffer
+		state.buffer = make([]*contracts.Envelope, 0, cap(state.buffer))
+	} else if len(state.buffer) > 0 {
+		// Start out with at least 16 slots
+		state.buffer = make([]*contracts.Envelope, 0, 16)
+	}
+
+	// Wait for an event
+	select {
+	case event := <-state.channel.collectChan:
+		if event == nil {
+			// Channel closed?  Not intercepted by Send()?
+			panic("Received nil event")
+		}
+
+		state.buffer = append(state.buffer, event)
+
+	case ctl := <-state.channel.controlChan:
+		// The buffer is empty, so there would be no point in flushing
+		state.channel.signalWhenDone(ctl.callback)
+
+		if ctl.stop {
+			state.stopping = true
+			return false
+		}
+	}
+
+	if len(state.buffer) == 0 {
+		return true
+	}
+
+	return state.waitToSend()
+}
+
+// Part of channel accept loop: Wait for buffer to fill, timeout to expire, or flush
+func (state *inMemoryChannelState) waitToSend() bool {
+	// Things that are used by the sender if we receive a control message
+	state.retryTimeout = 0
+	state.retry = true
+	state.callback = nil
+
+	// Delay until timeout passes or buffer fills up
+	state.timer.Reset(state.channel.batchInterval)
+	for {
+		if len(state.buffer) >= state.channel.batchSize {
+			state.timer.Stop()
+			return state.send()
+		}
+
+		select {
+		case event := <-state.channel.collectChan:
+			if event == nil {
+				// Channel closed?  Not intercepted by Send()?
+				panic("Received nil event")
+			}
+
+			state.buffer = append(state.buffer, event)
+
+		case ctl := <-state.channel.controlChan:
+			if ctl.stop {
+				state.stopping = true
+				state.retry = ctl.retry
+				if !ctl.flush {
+					// No flush? Just exit.
+					state.channel.signalWhenDone(ctl.callback)
+					return false
+				}
+			}
+
+			if ctl.flush {
+				state.timer.Stop()
+				state.retryTimeout = ctl.timeout
+				state.callback = ctl.callback
+				return state.send()
+			}
+
+		case <-state.timer.C:
+			// Timeout expired
+			return state.send()
+		}
+	}
+}
+
+// Part of channel accept loop: Check and wait on throttle, submit pending telemetry
+func (state *inMemoryChannelState) send() bool {
+	// Hold up transmission if we're being throttled
+	if !state.stopping && state.channel.throttle.isThrottled() {
+		if !state.waitThrottle() {
+			// Stopped
+			return false
+		}
+	}
+
+	// Send
+	if len(state.buffer) > 0 {
+		state.channel.waitgroup.Add(1)
+
+		// If we have a callback, wait on the waitgroup now that it's
+		// incremented.
+		state.channel.signalWhenDone(state.callback)
+
+		go func(buffer []*contracts.Envelope, retry bool, retryTimeout time.Duration) {
+			defer state.channel.waitgroup.Done()
+			state.channel.transmitRetry(buffer, retry, retryTimeout)
+		}(state.buffer, state.retry, state.retryTimeout)
+	} else if state.callback != nil {
+		state.channel.signalWhenDone(state.callback)
+	}
+
+	return true
+}
+
+// Part of channel accept loop: Wait for throttle to expire while dropping messages
+func (state *inMemoryChannelState) waitThrottle() bool {
+	// Channel is currently throttled.  Once the buffer fills, messages will
+	// be lost...  If we're exiting, then we'll just try to submit anyway.  That
+	// request may be throttled and transmitRetry will perform the backoff correctly.
+
+	throttleDone := state.channel.throttle.notifyWhenReady()
+	dropped := 0
+
+	defer func() {
+		if dropped > 0 {
+			state.channel.logf("Channel dropped %d events while throttled", dropped)
+		}
+	}()
+
+	for {
+		select {
+		case <-throttleDone:
+			close(throttleDone)
+			return true
+
+		case event := <-state.channel.collectChan:
+			// If there's still room in the buffer, then go ahead and add it.
+			if len(state.buffer) < state.channel.batchSize {
+				state.buffer = append(state.buffer, event)
+			} else {
+				dropped++
+			}
+
+		case ctl := <-state.channel.controlChan:
+			if ctl.stop {
+				state.stopping = true
+				state.retry = ctl.retry
+				if !ctl.flush {
+					state.channel.signalWhenDone(ctl.callback)
+					return false
+				} else {
+					// Make an exception when stopping
+					return true
+				}
+			}
+
+			// Cannot flush
+			// TODO: Figure out what to do about callback?
+			if ctl.flush {
+				state.channel.signalWhenDone(ctl.callback)
+			}
+		}
+	}
+}
+
+// Part of channel accept loop: Clean up and close telemetry channel
+func (state *inMemoryChannelState) stop() {
+	state.channel.closed.Store(true)
+	close(state.channel.collectChan)
+	close(state.channel.controlChan)
+
+	// Throttle can't close until transmitters are done using it.
+	state.channel.waitgroup.Wait()
+	state.channel.throttle.stop()
+}
+
+func (channel *inMemoryChannel) transmitRetry(items []*contracts.Envelope, retry bool, retryTimeout time.Duration) {
+	payload, err := serialize(items)
+	if err != nil {
+		channel.log(err)
+		if payload == nil {
+			return
+		}
+	}
+	retryTimeRemaining := retryTimeout
+
+	for _, wait := range submit_retries {
+		result, err := channel.transmitter.Transmit(payload, items)
+		if err != nil {
+			channel.logf("Error transmitting payload: %v", err)
+			return
+		}
+		if result.isSuccess() {
+			return
+		}
+		if !retry {
+			return
+		}
+
+		// Check for success, determine if we need to retry anything
+		if result.canRetry() {
+			// Filter down to failed items
+			payload, items = result.getRetryItems(payload, items)
+			if len(payload) == 0 || len(items) == 0 {
+				return
+			}
+		} else {
+			channel.logf("Cannot retry telemetry submission")
+			return
+		}
+
+		// Check for throttling
+		if result.isThrottled() {
+			if !result.retryAfter.IsZero() {
+				channel.throttle.retryAfter(result.retryAfter)
+			} else {
+				// TODO: Pick a time
+			}
path: telemetry/internal/appinsights/inmemorychannel.go
user: login: dagood
body: TODO spotted. I guess currently this means that a timeless throttle doesn't delay the retry at all?
created_at: 2025-05-06T17:26:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,8 @@
+// Package appinsights provides an interface to submit telemetry to Application Insights.
+// See more at https://azure.microsoft.com/en-us/services/application-insights/
path: telemetry/internal/appinsights/package.go
user: login: dagood
body: This redirects to the Azure Monitor product page, perhaps a more useful link:

```suggestion
// Package appinsights provides an interface to submit telemetry to Application Insights,
// a component of Azure Monitor. This package calls the Classic API.
// See https://learn.microsoft.com/en-us/azure/azure-monitor/app/api-custom-events-metrics
```
created_at: 2025-05-06T17:34:11Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,142 @@
+package appinsights
+
+import (
+	"time"
+)
+
+type throttleManager struct {
+	msgs chan *throttleMessage
+}
+
+type throttleMessage struct {
+	query     bool
+	wait      bool
+	throttle  bool
+	stop      bool
+	timestamp time.Time
+	result    chan bool
+}
+
+func newThrottleManager() *throttleManager {
path: telemetry/internal/appinsights/throttle.go
user: login: dagood
body: Would be nice to have some documentation about the intent of this struct and its methods.
created_at: 2025-05-06T17:36:16Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,453 @@
+package appinsights
+
+import (
+	"log"
+	"net/http"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+var (
+	submit_retries = []time.Duration{time.Duration(10 * time.Second), time.Duration(30 * time.Second), time.Duration(60 * time.Second)}
+)
+
+// inMemoryChannel stores events exclusively in memory.
+// Presently the only telemetry channel implementation available.
+type inMemoryChannel struct {
+	endpointAddr  string
+	collectChan   chan *contracts.Envelope
+	controlChan   chan *inMemoryChannelControl
+	batchSize     int
+	batchInterval time.Duration
+	waitgroup     sync.WaitGroup
+	throttle      *throttleManager
+	transmitter   transmitter
+	errorLog      *log.Logger
+
+	closed atomic.Bool
+}
+
+type inMemoryChannelControl struct {
+	// If true, flush the buffer.
+	flush bool
+
+	// If true, stop listening on the channel.  (Flush is required if any events are to be sent)
+	stop bool
+
+	// If stopping and flushing, this specifies whether to retry submissions on error.
+	retry bool
+
+	// If retrying, what is the max time to wait before finishing up?
+	timeout time.Duration
+
+	// If specified, a message will be sent on this channel when all pending telemetry items have been submitted
+	callback chan struct{}
+}
+
+// newInMemoryChannel creates an inMemoryChannel instance and starts a background submission goroutine.
+func newInMemoryChannel(endpointUrl string, batchSize int, batchInterval time.Duration, httpClient *http.Client) *inMemoryChannel {
+	channel := &inMemoryChannel{
+		endpointAddr:  endpointUrl,
+		collectChan:   make(chan *contracts.Envelope),
+		controlChan:   make(chan *inMemoryChannelControl),
+		batchSize:     batchSize,
+		batchInterval: batchInterval,
+		throttle:      newThrottleManager(),
+		transmitter:   newTransmitter(endpointUrl, httpClient),
+	}
+
+	go channel.acceptLoop()
+
+	return channel
+}
+
+func (channel *inMemoryChannel) log(v ...any) {
+	if channel.errorLog != nil {
+		channel.errorLog.Print(v...)
+	} else {
+		log.Print(v...)
+	}
+}
+
+func (channel *inMemoryChannel) logf(format string, args ...any) {
+	if channel.errorLog != nil {
+		channel.errorLog.Printf(format, args...)
+	} else {
+		log.Printf(format, args...)
+	}
+}
+
+// Queues a single telemetry item
+func (channel *inMemoryChannel) send(item *contracts.Envelope) {
+	if item != nil && !channel.closed.Load() {
+		channel.collectChan <- item
+	}
+}
+
+// Forces the current queue to be sent
+func (channel *inMemoryChannel) flush() {
+	if channel.closed.Load() {
+		return
+	}
+	channel.controlChan <- &inMemoryChannelControl{
+		flush: true,
+	}
+}
+
+// Tears down the submission goroutines, closes internal channels.  Any
+// telemetry waiting to be sent is discarded.  Further calls to Send() have
+// undefined behavior.  This is a more abrupt version of Close().
+func (channel *inMemoryChannel) stop() {
+	if channel.closed.Load() {
+		return
+	}
+	channel.controlChan <- &inMemoryChannelControl{
+		stop: true,
+	}
+}
+
+// Returns true if this channel has been throttled by the data collector.
+func (channel *inMemoryChannel) isThrottled() bool {
+	return channel.throttle != nil && channel.throttle.isThrottled()
+}
+
+// Flushes and tears down the submission goroutine and closes internal
+// channels.  Returns a channel that is closed when all pending telemetry
+// items have been submitted and it is safe to shut down without losing
+// telemetry.
+//
+// If retryTimeout is specified and non-zero, then failed submissions will
+// be retried until one succeeds or the timeout expires, whichever occurs
+// first.  A retryTimeout of zero indicates that failed submissions will be
+// retried as usual.  An omitted retryTimeout indicates that submissions
+// should not be retried if they fail.
+//
+// Note that the returned channel may not be closed before retryTimeout even
+// if it is specified.  This is because retryTimeout only applies to the
+// latest telemetry buffer.  This may be typical for applications that
+// submit a large amount of telemetry or are prone to being throttled.  When
+// exiting, you should select on the result channel and your own timer to
+// avoid long delays.
+func (channel *inMemoryChannel) close(timeout time.Duration) <-chan struct{} {
+	if channel.closed.Load() {
+		return nil
+	}
+	callback := make(chan struct{})
+
+	ctl := &inMemoryChannelControl{
+		stop:     true,
+		flush:    true,
+		retry:    false,
+		callback: callback,
+	}
+
+	if timeout != 0 {
+		ctl.retry = true
+		ctl.timeout = timeout
+	}
+
+	channel.controlChan <- ctl
+
+	return callback
+}
+
+func (channel *inMemoryChannel) acceptLoop() {
+	channelState := newInMemoryChannelState(channel)
+
+	for !channelState.stopping {
+		channelState.start()
+	}
+
+	channelState.stop()
+}
+
+// Data shared between parts of a channel
+type inMemoryChannelState struct {
+	channel      *inMemoryChannel
+	stopping     bool
+	buffer       []*contracts.Envelope
+	retry        bool
+	retryTimeout time.Duration
+	callback     chan struct{}
+	timer        *time.Timer
+}
+
+func newInMemoryChannelState(channel *inMemoryChannel) *inMemoryChannelState {
+	timer := time.NewTimer(time.Hour)
+	timer.Stop()
+	return &inMemoryChannelState{
+		channel:  channel,
+		buffer:   make([]*contracts.Envelope, 0, 16),
+		stopping: false,
+		timer:    timer,
+	}
+}
+
+// Part of channel accept loop: Initialize buffer and accept first message, handle controls.
+func (state *inMemoryChannelState) start() bool {
+	if len(state.buffer) > 16 {
+		// Start out with the size of the previous buffer
+		state.buffer = make([]*contracts.Envelope, 0, cap(state.buffer))
+	} else if len(state.buffer) > 0 {
+		// Start out with at least 16 slots
+		state.buffer = make([]*contracts.Envelope, 0, 16)
+	}
+
+	// Wait for an event
+	select {
+	case event := <-state.channel.collectChan:
+		if event == nil {
+			// Channel closed?  Not intercepted by Send()?
+			panic("Received nil event")
+		}
+
+		state.buffer = append(state.buffer, event)
+
+	case ctl := <-state.channel.controlChan:
+		// The buffer is empty, so there would be no point in flushing
+		state.channel.signalWhenDone(ctl.callback)
+
+		if ctl.stop {
+			state.stopping = true
+			return false
+		}
+	}
+
+	if len(state.buffer) == 0 {
+		return true
+	}
+
+	return state.waitToSend()
+}
+
+// Part of channel accept loop: Wait for buffer to fill, timeout to expire, or flush
+func (state *inMemoryChannelState) waitToSend() bool {
+	// Things that are used by the sender if we receive a control message
+	state.retryTimeout = 0
+	state.retry = true
+	state.callback = nil
+
+	// Delay until timeout passes or buffer fills up
+	state.timer.Reset(state.channel.batchInterval)
+	for {
+		if len(state.buffer) >= state.channel.batchSize {
+			state.timer.Stop()
+			return state.send()
+		}
+
+		select {
+		case event := <-state.channel.collectChan:
+			if event == nil {
+				// Channel closed?  Not intercepted by Send()?
+				panic("Received nil event")
+			}
+
+			state.buffer = append(state.buffer, event)
+
+		case ctl := <-state.channel.controlChan:
+			if ctl.stop {
+				state.stopping = true
+				state.retry = ctl.retry
+				if !ctl.flush {
+					// No flush? Just exit.
+					state.channel.signalWhenDone(ctl.callback)
+					return false
+				}
+			}
+
+			if ctl.flush {
+				state.timer.Stop()
+				state.retryTimeout = ctl.timeout
+				state.callback = ctl.callback
+				return state.send()
+			}
+
+		case <-state.timer.C:
+			// Timeout expired
+			return state.send()
+		}
+	}
+}
+
+// Part of channel accept loop: Check and wait on throttle, submit pending telemetry
+func (state *inMemoryChannelState) send() bool {
+	// Hold up transmission if we're being throttled
+	if !state.stopping && state.channel.throttle.isThrottled() {
+		if !state.waitThrottle() {
+			// Stopped
+			return false
+		}
+	}
+
+	// Send
+	if len(state.buffer) > 0 {
+		state.channel.waitgroup.Add(1)
+
+		// If we have a callback, wait on the waitgroup now that it's
+		// incremented.
+		state.channel.signalWhenDone(state.callback)
+
+		go func(buffer []*contracts.Envelope, retry bool, retryTimeout time.Duration) {
+			defer state.channel.waitgroup.Done()
+			state.channel.transmitRetry(buffer, retry, retryTimeout)
+		}(state.buffer, state.retry, state.retryTimeout)
+	} else if state.callback != nil {
+		state.channel.signalWhenDone(state.callback)
+	}
+
+	return true
+}
+
+// Part of channel accept loop: Wait for throttle to expire while dropping messages
+func (state *inMemoryChannelState) waitThrottle() bool {
+	// Channel is currently throttled.  Once the buffer fills, messages will
+	// be lost...  If we're exiting, then we'll just try to submit anyway.  That
+	// request may be throttled and transmitRetry will perform the backoff correctly.
+
+	throttleDone := state.channel.throttle.notifyWhenReady()
+	dropped := 0
+
+	defer func() {
+		if dropped > 0 {
+			state.channel.logf("Channel dropped %d events while throttled", dropped)
+		}
+	}()
+
+	for {
+		select {
+		case <-throttleDone:
+			close(throttleDone)
+			return true
+
+		case event := <-state.channel.collectChan:
+			// If there's still room in the buffer, then go ahead and add it.
+			if len(state.buffer) < state.channel.batchSize {
+				state.buffer = append(state.buffer, event)
+			} else {
+				dropped++
+			}
+
+		case ctl := <-state.channel.controlChan:
+			if ctl.stop {
+				state.stopping = true
+				state.retry = ctl.retry
+				if !ctl.flush {
+					state.channel.signalWhenDone(ctl.callback)
+					return false
+				} else {
+					// Make an exception when stopping
+					return true
+				}
+			}
+
+			// Cannot flush
+			// TODO: Figure out what to do about callback?
+			if ctl.flush {
+				state.channel.signalWhenDone(ctl.callback)
+			}
+		}
+	}
+}
+
+// Part of channel accept loop: Clean up and close telemetry channel
+func (state *inMemoryChannelState) stop() {
+	state.channel.closed.Store(true)
+	close(state.channel.collectChan)
+	close(state.channel.controlChan)
+
+	// Throttle can't close until transmitters are done using it.
+	state.channel.waitgroup.Wait()
+	state.channel.throttle.stop()
+}
+
+func (channel *inMemoryChannel) transmitRetry(items []*contracts.Envelope, retry bool, retryTimeout time.Duration) {
+	payload, err := serialize(items)
+	if err != nil {
+		channel.log(err)
+		if payload == nil {
+			return
+		}
+	}
+	retryTimeRemaining := retryTimeout
+
+	for _, wait := range submit_retries {
+		result, err := channel.transmitter.Transmit(payload, items)
+		if err != nil {
+			channel.logf("Error transmitting payload: %v", err)
+			return
+		}
+		if result.isSuccess() {
+			return
+		}
+		if !retry {
+			return
+		}
+
+		// Check for success, determine if we need to retry anything
+		if result.canRetry() {
+			// Filter down to failed items
+			payload, items = result.getRetryItems(payload, items)
+			if len(payload) == 0 || len(items) == 0 {
+				return
+			}
+		} else {
+			channel.logf("Cannot retry telemetry submission")
+			return
+		}
+
+		// Check for throttling
+		if result.isThrottled() {
+			if !result.retryAfter.IsZero() {
+				channel.throttle.retryAfter(result.retryAfter)
+			} else {
+				// TODO: Pick a time
+			}
path: telemetry/internal/appinsights/inmemorychannel.go
user: login: qmuntal
body: Fixed. The retry will use the `Client.MaxBatchInterval` as delay.
created_at: 2025-05-07T10:20:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,453 @@
+package appinsights
+
+import (
+	"log"
+	"net/http"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+var (
+	submit_retries = []time.Duration{time.Duration(10 * time.Second), time.Duration(30 * time.Second), time.Duration(60 * time.Second)}
+)
+
+// inMemoryChannel stores events exclusively in memory.
+// Presently the only telemetry channel implementation available.
+type inMemoryChannel struct {
+	endpointAddr  string
+	collectChan   chan *contracts.Envelope
+	controlChan   chan *inMemoryChannelControl
+	batchSize     int
+	batchInterval time.Duration
+	waitgroup     sync.WaitGroup
+	throttle      *throttleManager
+	transmitter   transmitter
+	errorLog      *log.Logger
+
+	closed atomic.Bool
+}
+
+type inMemoryChannelControl struct {
+	// If true, flush the buffer.
+	flush bool
+
+	// If true, stop listening on the channel.  (Flush is required if any events are to be sent)
+	stop bool
+
+	// If stopping and flushing, this specifies whether to retry submissions on error.
+	retry bool
+
+	// If retrying, what is the max time to wait before finishing up?
+	timeout time.Duration
+
+	// If specified, a message will be sent on this channel when all pending telemetry items have been submitted
+	callback chan struct{}
+}
+
+// newInMemoryChannel creates an inMemoryChannel instance and starts a background submission goroutine.
+func newInMemoryChannel(endpointUrl string, batchSize int, batchInterval time.Duration, httpClient *http.Client) *inMemoryChannel {
+	channel := &inMemoryChannel{
+		endpointAddr:  endpointUrl,
+		collectChan:   make(chan *contracts.Envelope),
+		controlChan:   make(chan *inMemoryChannelControl),
+		batchSize:     batchSize,
+		batchInterval: batchInterval,
+		throttle:      newThrottleManager(),
+		transmitter:   newTransmitter(endpointUrl, httpClient),
+	}
+
+	go channel.acceptLoop()
+
+	return channel
+}
+
+func (channel *inMemoryChannel) log(v ...any) {
+	if channel.errorLog != nil {
+		channel.errorLog.Print(v...)
+	} else {
+		log.Print(v...)
+	}
+}
+
+func (channel *inMemoryChannel) logf(format string, args ...any) {
+	if channel.errorLog != nil {
+		channel.errorLog.Printf(format, args...)
+	} else {
+		log.Printf(format, args...)
+	}
+}
+
+// Queues a single telemetry item
+func (channel *inMemoryChannel) send(item *contracts.Envelope) {
+	if item != nil && !channel.closed.Load() {
+		channel.collectChan <- item
+	}
+}
+
+// Forces the current queue to be sent
+func (channel *inMemoryChannel) flush() {
+	if channel.closed.Load() {
+		return
+	}
+	channel.controlChan <- &inMemoryChannelControl{
+		flush: true,
+	}
+}
+
+// Tears down the submission goroutines, closes internal channels.  Any
+// telemetry waiting to be sent is discarded.  Further calls to Send() have
+// undefined behavior.  This is a more abrupt version of Close().
+func (channel *inMemoryChannel) stop() {
+	if channel.closed.Load() {
+		return
+	}
+	channel.controlChan <- &inMemoryChannelControl{
+		stop: true,
+	}
+}
+
+// Returns true if this channel has been throttled by the data collector.
+func (channel *inMemoryChannel) isThrottled() bool {
+	return channel.throttle != nil && channel.throttle.isThrottled()
+}
+
+// Flushes and tears down the submission goroutine and closes internal
+// channels.  Returns a channel that is closed when all pending telemetry
+// items have been submitted and it is safe to shut down without losing
+// telemetry.
+//
+// If retryTimeout is specified and non-zero, then failed submissions will
+// be retried until one succeeds or the timeout expires, whichever occurs
+// first.  A retryTimeout of zero indicates that failed submissions will be
+// retried as usual.  An omitted retryTimeout indicates that submissions
+// should not be retried if they fail.
+//
+// Note that the returned channel may not be closed before retryTimeout even
+// if it is specified.  This is because retryTimeout only applies to the
+// latest telemetry buffer.  This may be typical for applications that
+// submit a large amount of telemetry or are prone to being throttled.  When
+// exiting, you should select on the result channel and your own timer to
+// avoid long delays.
+func (channel *inMemoryChannel) close(timeout time.Duration) <-chan struct{} {
+	if channel.closed.Load() {
+		return nil
+	}
+	callback := make(chan struct{})
+
+	ctl := &inMemoryChannelControl{
+		stop:     true,
+		flush:    true,
+		retry:    false,
+		callback: callback,
+	}
+
+	if timeout != 0 {
+		ctl.retry = true
+		ctl.timeout = timeout
+	}
+
+	channel.controlChan <- ctl
+
+	return callback
+}
+
+func (channel *inMemoryChannel) acceptLoop() {
+	channelState := newInMemoryChannelState(channel)
+
+	for !channelState.stopping {
+		channelState.start()
+	}
+
+	channelState.stop()
+}
+
+// Data shared between parts of a channel
+type inMemoryChannelState struct {
+	channel      *inMemoryChannel
+	stopping     bool
+	buffer       []*contracts.Envelope
+	retry        bool
+	retryTimeout time.Duration
+	callback     chan struct{}
+	timer        *time.Timer
+}
+
+func newInMemoryChannelState(channel *inMemoryChannel) *inMemoryChannelState {
+	timer := time.NewTimer(time.Hour)
+	timer.Stop()
+	return &inMemoryChannelState{
+		channel:  channel,
+		buffer:   make([]*contracts.Envelope, 0, 16),
+		stopping: false,
+		timer:    timer,
+	}
+}
+
+// Part of channel accept loop: Initialize buffer and accept first message, handle controls.
+func (state *inMemoryChannelState) start() bool {
+	if len(state.buffer) > 16 {
+		// Start out with the size of the previous buffer
+		state.buffer = make([]*contracts.Envelope, 0, cap(state.buffer))
+	} else if len(state.buffer) > 0 {
+		// Start out with at least 16 slots
+		state.buffer = make([]*contracts.Envelope, 0, 16)
+	}
+
+	// Wait for an event
+	select {
+	case event := <-state.channel.collectChan:
+		if event == nil {
+			// Channel closed?  Not intercepted by Send()?
+			panic("Received nil event")
+		}
+
+		state.buffer = append(state.buffer, event)
+
+	case ctl := <-state.channel.controlChan:
+		// The buffer is empty, so there would be no point in flushing
+		state.channel.signalWhenDone(ctl.callback)
+
+		if ctl.stop {
+			state.stopping = true
+			return false
+		}
+	}
+
+	if len(state.buffer) == 0 {
+		return true
+	}
+
+	return state.waitToSend()
+}
+
+// Part of channel accept loop: Wait for buffer to fill, timeout to expire, or flush
+func (state *inMemoryChannelState) waitToSend() bool {
+	// Things that are used by the sender if we receive a control message
+	state.retryTimeout = 0
+	state.retry = true
+	state.callback = nil
+
+	// Delay until timeout passes or buffer fills up
+	state.timer.Reset(state.channel.batchInterval)
+	for {
+		if len(state.buffer) >= state.channel.batchSize {
+			state.timer.Stop()
+			return state.send()
+		}
+
+		select {
+		case event := <-state.channel.collectChan:
+			if event == nil {
+				// Channel closed?  Not intercepted by Send()?
+				panic("Received nil event")
+			}
+
+			state.buffer = append(state.buffer, event)
+
+		case ctl := <-state.channel.controlChan:
+			if ctl.stop {
+				state.stopping = true
+				state.retry = ctl.retry
+				if !ctl.flush {
+					// No flush? Just exit.
+					state.channel.signalWhenDone(ctl.callback)
+					return false
+				}
+			}
+
+			if ctl.flush {
+				state.timer.Stop()
+				state.retryTimeout = ctl.timeout
+				state.callback = ctl.callback
+				return state.send()
+			}
+
+		case <-state.timer.C:
+			// Timeout expired
+			return state.send()
+		}
+	}
+}
+
+// Part of channel accept loop: Check and wait on throttle, submit pending telemetry
+func (state *inMemoryChannelState) send() bool {
+	// Hold up transmission if we're being throttled
+	if !state.stopping && state.channel.throttle.isThrottled() {
+		if !state.waitThrottle() {
+			// Stopped
+			return false
+		}
+	}
+
+	// Send
+	if len(state.buffer) > 0 {
+		state.channel.waitgroup.Add(1)
+
+		// If we have a callback, wait on the waitgroup now that it's
+		// incremented.
+		state.channel.signalWhenDone(state.callback)
+
+		go func(buffer []*contracts.Envelope, retry bool, retryTimeout time.Duration) {
+			defer state.channel.waitgroup.Done()
+			state.channel.transmitRetry(buffer, retry, retryTimeout)
+		}(state.buffer, state.retry, state.retryTimeout)
path: telemetry/internal/appinsights/inmemorychannel.go
user: login: qmuntal
body: Yeah, I also dislike this code. Will improve it 😄.
created_at: 2025-05-07T10:21:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,184 @@
+package appinsights
path: telemetry/internal/appinsights/client.go
user: login: qmuntal
body: Good catch. Added.
created_at: 2025-05-07T10:22:25Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,184 @@
+package appinsights
+
+import (
+	"cmp"
+	"context"
+	"log"
+	"maps"
+	"net/http"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+// Client is the main entry point for sending telemetry to Application Insights.
+// Changing its properties after a telemetry item is created will have no effect.
+type Client struct {
+	// The instrumentation key used to identify the application.
+	// This key is required and must be set before sending any telemetry.
+	InstrumentationKey string
+
+	// The endpoint URL to which telemetry will be sent.
+	// If empty, it defaults to https://dc.services.visualstudio.com/v2/track.
+	Endpoint string
+
+	// Maximum number of telemetry items that can be submitted in each
+	// request. If this many items are buffered, the buffer will be
+	// flushed before MaxBatchInterval expires.
+	// If zero, it defaults to 1024.
+	MaxBatchSize int
+
+	// Maximum time to wait before sending a batch of telemetry.
+	// If zero, it defaults to 10 seconds.
+	MaxBatchInterval time.Duration
+
+	// Customized http client.
+	// If nil, it defaults to http.DefaultClient.
+	HTTPClient *http.Client
+
+	// Tags to be sent with every telemetry item.
+	// If nil, no additional tags will be sent.
+	Tags map[string]string
+
+	// ErrorLog specifies an optional logger for errors
+	// that occur when attempting to upload telemetry.
+	// If nil, logging is done via the log package's standard logger.
+	ErrorLog *log.Logger
+
+	channel  *inMemoryChannel
+	context  *telemetryContext
+	disabled bool
+
+	initialized atomic.Bool
+	initOnce    sync.Once
+}
+
+// init initializes the client.
+// It is safe to call this method multiple times concurrently.
+func (c *Client) init() {
+	c.initOnce.Do(func() {
+		if c.InstrumentationKey == "" {
+			panic("instrumentation key is required")
+		}
+		endpoint := cmp.Or(c.Endpoint, "https://dc.services.visualstudio.com/v2/track")
+		batchSize := cmp.Or(c.MaxBatchSize, 1024)
+		batchInterval := cmp.Or(c.MaxBatchInterval, 10*time.Second)
+		httpClient := cmp.Or(c.HTTPClient, http.DefaultClient)
+		c.channel = newInMemoryChannel(endpoint, batchSize, batchInterval, httpClient)
+		c.context = setupContext(c.InstrumentationKey, c.Tags)
+		if err := contracts.SanitizeTags(c.context.Tags); err != nil {
+			c.ErrorLog.Printf("Warning sanitizing tags: %v", err)
+		}
+		c.initialized.Store(true)
+	})
+}
+
+func setupContext(instrumentationKey string, tags map[string]string) *telemetryContext {
+	context := newTelemetryContext(instrumentationKey)
+	context.Tags["ai.internal.sdkVersion"] = sdkName + ":" + version
+	maps.Copy(context.Tags, tags)
+	return context
+}
+
+// Enabled returns true if this client is enabled and will accept telemetry.
+func (c *Client) Enabled() bool {
+	return !c.disabled
+}
+
+// SetEnabled enables or disables the client. When disabled, telemetry is
+// silently swallowed by the client. Defaults to enabled.
+func (c *Client) SetEnabled(enabled bool) {
+	c.disabled = !enabled
+}
+
+// NewEvent creates a new event with the specified name.
+func (c *Client) NewEvent(name string) *Event {
+	return &Event{
+		name:   name,
+		client: c,
+	}
+}
+
+// TrackEvent logs a user action with the specified name.
+func (c *Client) TrackEvent(name string) {
+	c.NewEvent(name).Inc()
+}
+
+// Forces the current queue to be sent.
+func (c *Client) Flush() {
+	if !c.initialized.Load() {
+		return
+	}
+	c.channel.flush()
+}
+
+// Close flushes and tears down the submission goroutine and closes internal channels.
+// Waits until all pending telemetry items have been submitted.
+func (c *Client) Close(ctx context.Context) error {
+	if !c.initialized.Load() {
+		return nil
+	}
+	var d time.Duration
+	if timeout, ok := ctx.Deadline(); ok {
+		d = time.Until(timeout)
+		if d == 0 {
+			// Don't confuse the user with a zero duration.
+			d = -1
+		}
+	}
+	select {
+	case <-ctx.Done():
+		return ctx.Err()
+	case <-c.channel.close(d):
+		// TODO: check for errors.
+		return nil
+	}
+}
+
+// Tears down the submission goroutines, closes internal channels.
+// Any telemetry waiting to be sent is discarded.
+// This is a more abrupt version of [Client.Close].
+func (c *Client) Stop() {
+	if !c.initialized.Load() {
+		return
+	}
+	c.channel.stop()
+}
+
+// Submits the specified telemetry item.
+func (c *Client) track(data contracts.EventData) {
+	if !c.disabled {
+		c.init()
+		ev := c.context.envelop(data)
+		if err := ev.Sanitize(); err != nil {
+			c.ErrorLog.Printf("Warning sanitizing telemetry item: %v", err)
+		}
+		c.channel.send(c.context.envelop(data))
+	}
+}
+
+// Event represents an event to be tracked.
+type Event struct {
+	name   string
+	client *Client
+}
+
+// Inc adds 1 to the counter.
+func (e *Event) Inc() {
+	e.Add(1)
+}
+
+// Add adds n to the counter. n cannot be negative, as counts cannot decrease.
+func (e *Event) Add(n uint64) {
+	if n == 0 {
+		return
+	}
+	for range n {
+		e.client.track(contracts.EventData{
path: telemetry/internal/appinsights/client.go
user: login: qmuntal
body: I've optimized this method a bit. I still have to add tests for this method to see if how well it behave.
created_at: 2025-05-07T10:42:12Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,453 @@
+package appinsights
+
+import (
+	"log"
+	"net/http"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+var (
+	submit_retries = []time.Duration{time.Duration(10 * time.Second), time.Duration(30 * time.Second), time.Duration(60 * time.Second)}
+)
+
+// inMemoryChannel stores events exclusively in memory.
+// Presently the only telemetry channel implementation available.
+type inMemoryChannel struct {
+	endpointAddr  string
+	collectChan   chan *contracts.Envelope
+	controlChan   chan *inMemoryChannelControl
+	batchSize     int
+	batchInterval time.Duration
+	waitgroup     sync.WaitGroup
+	throttle      *throttleManager
+	transmitter   transmitter
+	errorLog      *log.Logger
+
+	closed atomic.Bool
+}
+
+type inMemoryChannelControl struct {
+	// If true, flush the buffer.
+	flush bool
+
+	// If true, stop listening on the channel.  (Flush is required if any events are to be sent)
+	stop bool
+
+	// If stopping and flushing, this specifies whether to retry submissions on error.
+	retry bool
+
+	// If retrying, what is the max time to wait before finishing up?
+	timeout time.Duration
+
+	// If specified, a message will be sent on this channel when all pending telemetry items have been submitted
+	callback chan struct{}
+}
+
+// newInMemoryChannel creates an inMemoryChannel instance and starts a background submission goroutine.
path: telemetry/internal/appinsights/inmemorychannel.go
user: login: qmuntal
body: Good point. I've moved the `acceptLoop` call outside of this constructor to let the caller control when the loop is started.
created_at: 2025-05-07T10:44:36Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,142 @@
+package appinsights
+
+import (
+	"time"
+)
+
+type throttleManager struct {
+	msgs chan *throttleMessage
+}
+
+type throttleMessage struct {
+	query     bool
+	wait      bool
+	throttle  bool
+	stop      bool
+	timestamp time.Time
+	result    chan bool
+}
+
+func newThrottleManager() *throttleManager {
path: telemetry/internal/appinsights/throttle.go
user: login: qmuntal
body: I've removed this struct altogether. Too complicated with little benefit.
created_at: 2025-05-07T10:45:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,102 @@
+//go:build goexperiment.synctest
+
+package appinsights
+
+import (
+	"bytes"
+	"compress/gzip"
+	"context"
+	"io"
+	"log"
+	"net/http"
+	"testing"
+	"testing/synctest"
+)
+
+type fakeTransport struct {
+	code int
+	body []byte
+	err  error
+}
+
+func (t fakeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+	return &http.Response{
+		StatusCode: t.code,
+		Body:       io.NopCloser(bytes.NewBuffer(t.body)),
+	}, t.err
+}
+
+func BenchmarkClientBurstPerformance(b *testing.B) {
+	client := &Client{
+		InstrumentationKey: test_ikey,
+		ErrorLog:           log.New(io.Discard, "", 0),
+		HTTPClient: &http.Client{
+			Transport: fakeTransport{
+				code: http.StatusOK,
+				body: []byte(`{"itemsReceived":4, "itemsAccepted":4, "errors":[]}`),
+			},
+		},
+	}
+
+	for b.Loop() {
+		client.TrackEvent("A message")
+	}
+
+	client.Close(context.Background())
+}
+
+func TestEndToEnd(t *testing.T) {
+	synctest.Run(func() {
+		xmit, server := newTestClientServer(t)
+		defer server.Close()
+
+		// Set up server response
+		server.responseData = []byte(`{"itemsReceived":4, "itemsAccepted":4, "errors":[]}`)
+		server.responseHeaders["Content-type"] = "application/json"
+
+		client := &Client{InstrumentationKey: test_ikey, Endpoint: xmit.(*httpTransmitter).endpoint, HTTPClient: xmit.(*httpTransmitter).client}
+		defer client.Close(context.Background())
+
+		// Track directly off the client
+		client.TrackEvent("client-event")
+
+		// NOTE: A lot of this is covered elsewhere, so we won't duplicate
+		// *too* much.
+
+		// Wait for automatic transmit -- get the request
+		slowTick(11)
+		req := server.waitForRequest(t)
+
+		// GZIP magic number
+		if len(req.body) < 2 || req.body[0] != 0x1f || req.body[1] != 0x8b {
+			t.Fatal("Missing gzip magic number")
+		}
+
+		// Decompress
+		reader, err := gzip.NewReader(bytes.NewReader(req.body))
+		if err != nil {
+			t.Fatalf("Coudln't create gzip reader: %s", err.Error())
+		}
+
+		// Read payload
+		body, err := io.ReadAll(reader)
+		reader.Close()
+		if err != nil {
+			t.Fatalf("Couldn't read compressed data: %s", err.Error())
+		}
path: telemetry/internal/appinsights/client_test.go
user: login: qmuntal
body: Good point. Done.
created_at: 2025-05-07T14:16:00Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,393 @@
+//go:build goexperiment.synctest
path: telemetry/internal/appinsights/client_test.go
user: login: dagood
body: ```suggestion
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

//go:build goexperiment.synctest
```
created_at: 2025-05-13T20:54:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,311 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package appinsights
+
+import (
+	"context"
+	"errors"
+	"log"
+	"net/http"
+	"slices"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+// batchItem is a telemetry item that is sent in a batch.
+type batchItem struct {
+	item    *contracts.Envelope
+	retries int
+}
+
+// inMemoryChannel stores events exclusively in memory.
+// Presently the only telemetry channel implementation available.
+type inMemoryChannel struct {
+	endpointAddr  string
+	batchSize     int
+	batchInterval time.Duration
+	errorLog      *log.Logger
+
+	collectChan chan *contracts.Envelope
+	flushChan   chan struct{}
+	retryChan   chan retryMessage
+
+	transmitter transmitter
+
+	// Use a context instead of a channel to
+	// allow propagating the cancellation to the
+	// transmitter and to the underlying HTTP client.
+	cancelCtx       context.Context
+	cancelCauseFunc context.CancelCauseFunc
+
+	throttled   atomic.Bool
+	closed      atomic.Bool
+	sendQueue   []*[]batchItem
+	sendQueueMu sync.Mutex
+	sendMu      sync.Mutex
+}
+
+type retryMessage struct {
+	throttled  bool
+	retryAfter time.Time
+	items      []batchItem
+}
+
+// newInMemoryChannel creates an inMemoryChannel instance and starts a background submission goroutine.
+func newInMemoryChannel(endpointUrl string, batchSize int, batchInterval time.Duration, httpClient *http.Client, errorLog *log.Logger) *inMemoryChannel {
+	// Set up the channel
+	channel := &inMemoryChannel{
+		endpointAddr:  endpointUrl,
+		batchSize:     batchSize,
+		batchInterval: batchInterval,
+		errorLog:      errorLog,
+		collectChan:   make(chan *contracts.Envelope),
+		flushChan:     make(chan struct{}),
+		retryChan:     make(chan retryMessage),
+		transmitter:   newTransmitter(endpointUrl, httpClient),
+	}
+	channel.cancelCtx, channel.cancelCauseFunc = context.WithCancelCause(context.Background())
+	return channel
+}
+
+func (channel *inMemoryChannel) logf(format string, args ...any) {
+	if channel.errorLog != nil {
+		channel.errorLog.Printf(format, args...)
+	} else {
+		log.Printf(format, args...)
+	}
+}
+
+// Queues a single telemetry item
+func (channel *inMemoryChannel) send(item *contracts.Envelope) {
+	if item != nil && !channel.closed.Load() {
+		channel.collectChan <- item
+	}
+}
+
+// Forces the current queue to be sent
+func (channel *inMemoryChannel) flush() {
+	if channel.closed.Load() {
+		return
+	}
+	channel.flushChan <- struct{}{}
+}
+
+func (channel *inMemoryChannel) retry(throttled bool, retryAfter time.Time, items []batchItem) {
+	// Retry even if the channel is closed to allow for
+	// retrying items that were already sent.
+	channel.retryChan <- retryMessage{throttled, retryAfter, items}
+}
+
+var errStopped = errors.New("client stopped")
+var errClosed = errors.New("client closed")
+
+func (channel *inMemoryChannel) stop() {
+	if channel.closed.Load() {
+		return
+	}
+
+	channel.closed.Store(true)
+	channel.cancelCauseFunc(errStopped)
+}
+
+// Flushes and tears down the submission goroutine and closes internal
+// channels.  Returns a channel that is closed when all pending telemetry
+// items have been submitted and it is safe to shut down without losing
+// telemetry.
path: telemetry/internal/appinsights/inmemorychannel.go
user: login: dagood
body: Doesn't seem accurate?

```suggestion
// close flushes and tears down the submission goroutine and closes internal
// channels. Returns when all pending telemetry items have been submitted
// (it is then safe to shut down without losing telemetry) or when
// the context is canceled.
```
created_at: 2025-05-13T22:06:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,311 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package appinsights
+
+import (
+	"context"
+	"errors"
+	"log"
+	"net/http"
+	"slices"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+// batchItem is a telemetry item that is sent in a batch.
+type batchItem struct {
+	item    *contracts.Envelope
+	retries int
+}
+
+// inMemoryChannel stores events exclusively in memory.
+// Presently the only telemetry channel implementation available.
+type inMemoryChannel struct {
+	endpointAddr  string
+	batchSize     int
+	batchInterval time.Duration
+	errorLog      *log.Logger
+
+	collectChan chan *contracts.Envelope
+	flushChan   chan struct{}
+	retryChan   chan retryMessage
+
+	transmitter transmitter
+
+	// Use a context instead of a channel to
+	// allow propagating the cancellation to the
+	// transmitter and to the underlying HTTP client.
+	cancelCtx       context.Context
+	cancelCauseFunc context.CancelCauseFunc
+
+	throttled   atomic.Bool
+	closed      atomic.Bool
+	sendQueue   []*[]batchItem
+	sendQueueMu sync.Mutex
+	sendMu      sync.Mutex
+}
+
+type retryMessage struct {
+	throttled  bool
+	retryAfter time.Time
+	items      []batchItem
+}
+
+// newInMemoryChannel creates an inMemoryChannel instance and starts a background submission goroutine.
+func newInMemoryChannel(endpointUrl string, batchSize int, batchInterval time.Duration, httpClient *http.Client, errorLog *log.Logger) *inMemoryChannel {
+	// Set up the channel
+	channel := &inMemoryChannel{
+		endpointAddr:  endpointUrl,
+		batchSize:     batchSize,
+		batchInterval: batchInterval,
+		errorLog:      errorLog,
+		collectChan:   make(chan *contracts.Envelope),
+		flushChan:     make(chan struct{}),
+		retryChan:     make(chan retryMessage),
+		transmitter:   newTransmitter(endpointUrl, httpClient),
+	}
+	channel.cancelCtx, channel.cancelCauseFunc = context.WithCancelCause(context.Background())
+	return channel
+}
+
+func (channel *inMemoryChannel) logf(format string, args ...any) {
+	if channel.errorLog != nil {
+		channel.errorLog.Printf(format, args...)
+	} else {
+		log.Printf(format, args...)
+	}
+}
+
+// Queues a single telemetry item
+func (channel *inMemoryChannel) send(item *contracts.Envelope) {
+	if item != nil && !channel.closed.Load() {
+		channel.collectChan <- item
+	}
+}
+
+// Forces the current queue to be sent
+func (channel *inMemoryChannel) flush() {
+	if channel.closed.Load() {
+		return
+	}
+	channel.flushChan <- struct{}{}
+}
+
+func (channel *inMemoryChannel) retry(throttled bool, retryAfter time.Time, items []batchItem) {
+	// Retry even if the channel is closed to allow for
+	// retrying items that were already sent.
+	channel.retryChan <- retryMessage{throttled, retryAfter, items}
+}
+
+var errStopped = errors.New("client stopped")
+var errClosed = errors.New("client closed")
+
+func (channel *inMemoryChannel) stop() {
+	if channel.closed.Load() {
+		return
+	}
+
+	channel.closed.Store(true)
+	channel.cancelCauseFunc(errStopped)
+}
+
+// Flushes and tears down the submission goroutine and closes internal
+// channels.  Returns a channel that is closed when all pending telemetry
+// items have been submitted and it is safe to shut down without losing
+// telemetry.
+func (channel *inMemoryChannel) close(ctx context.Context) {
+	if channel.closed.Load() {
+		return
+	}
+
+	channel.closed.Store(true)
+	channel.flushChan <- struct{}{}
+	select {
+	case <-ctx.Done():
+		channel.cancelCauseFunc(context.Cause(ctx))
+	case <-channel.cancelCtx.Done():
+		// Successfully flushed
+	}
+}
+
+func (channel *inMemoryChannel) acceptLoop() {
+	channel.start()
+}
+
+// Part of channel accept loop: Initialize buffer and accept first message, handle controls.
+func (channel *inMemoryChannel) start() {
+	items := make([]batchItem, 0, channel.batchSize)
+	timer := time.NewTimer(time.Hour)
+	timer.Stop() // Stop timer until we need it.
+	var dropped int
+	for {
+		select {
+		case item := <-channel.collectChan:
+			if item == nil {
+				panic("received nil event")
+			}
+			if channel.throttled.Load() {
+				// Check if there is space to add the item to the batch.
+				// If not, then drop the event.
+				if len(items) < channel.batchSize {
+					items = append(items, batchItem{item, 0})
+				} else {
+					dropped++
+				}
+				continue
+			}
+			items = append(items, batchItem{item, 0})
+			if len(items) >= channel.batchSize {
+				timer.Stop()
+				channel.sendBatch(items)
+				items = items[:0]
+			} else if len(items) == 1 {
+				// Start the timer if this is the first item in the batch.
+				timer.Reset(channel.batchInterval)
+			}
+
+		case <-channel.flushChan:
+			if channel.throttled.Load() {
+				// Ignore the flush request if we are throttled.
+				continue
+			}
+			timer.Stop()
+			channel.sendBatch(items)
+			items = items[:0]
+
+		case <-timer.C:
+			if channel.throttled.Load() {
+				// When throttled, the timer is reset to the retry time,
+				// so if we get here, then we're no longer throttled.
+				channel.throttled.Store(false)
+				if dropped > 0 {
+					channel.logf("Dropped %d telemetry items due to throttling", dropped)
+					dropped = 0
+				}
+			}
+			channel.sendBatch(items)
+			items = items[:0]
+
+		case msg := <-channel.retryChan:
+			// If there is not enough space in the batch, drop the items.
+			space := channel.batchSize - len(items)
+			if space < len(msg.items) {
+				dropped += len(msg.items) - space
+				msg.items = msg.items[:space]
+			}
+			items = append(items, msg.items...)
+
+			if msg.throttled {
+				channel.throttled.Store(true)
+			}
+			if msg.retryAfter.IsZero() {
+				// If the retry time is not set, use the default batch interval.
+				timer.Reset(channel.batchInterval)
+			} else {
+				timer.Reset(time.Until(msg.retryAfter))
+			}
+
+		case <-channel.cancelCtx.Done():
+			// This is the only path to exit the loop.
+			timer.Stop()
+			return
+		}
+	}
+}
+
+var itemsBuf = sync.Pool{
+	New: func() any {
+		buf := make([]batchItem, 0, 1024)
+		return &buf
+	},
+}
+
+// sendBatch schedules a batch of items for transmission.
+func (channel *inMemoryChannel) sendBatch(items []batchItem) {
+	channel.sendQueueMu.Lock()
+	defer channel.sendQueueMu.Unlock()
+
+	if len(items) == 0 {
+		// Cancel the accept loop if we are closed and the queue is empty.
+		if len(channel.sendQueue) == 0 && channel.closed.Load() {
+			channel.cancelCauseFunc(errClosed)
+		}
+		return
+	}
+
+	// Copy the items to a temporary buffer to let the caller
+	// reuse the item slice.
+	buf := itemsBuf.Get().(*[]batchItem)
+	*buf = (*buf)[:0]
+	*buf = append(*buf, items...)
path: telemetry/internal/appinsights/inmemorychannel.go
user: login: dagood
body: I'm vaguely aware of [a concern with `sync.Pool` usage for buffers](https://github.com/golang/go/issues/23199) where if you sometimes grow the buffers, you might end up accumulating lots of buffers that are all needlessly high-capacity, putting pressure on memory. Currently each of these bufs have 1024 cap, and if `items` is longer than that, it looks like this buf would grow, running into that gotcha.

I don't know for sure if that concern applies here or not, but if so, perhaps we should e.g. grab multiple bufs from the pool if `len(items)` > 1024 rather than growing the buf we happen to get?
created_at: 2025-05-13T22:22:37Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,105 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package contracts
+
+import (
+	"errors"
+	"time"
+)
+
+// Data struct to contain both B and C sections.
+type Data struct {
+	BaseType string    `json:"baseType"`
+	BaseData EventData `json:"baseData"`
+}
+
+// Instances of Event represent structured event records that can be grouped
+// and searched by their properties. Event data item also creates a metric of
+// event count by name.
+type EventData struct {
+	// Schema version
+	Ver int `json:"ver"`
+
+	// Event name. Keep it low cardinality to allow proper grouping and useful
+	// metrics.
+	Name string `json:"name"`
+}
+
+// System variables for a telemetry item.
+type Envelope struct {
+	// Envelope version. For internal use only. By assigning this the default, it
+	// will not be serialized within the payload unless changed to a value other
+	// than #1.
+	Ver int `json:"ver"`
+
+	// Type name of telemetry data item.
+	Name string `json:"name"`
+
+	// Event date time when telemetry item was created. This is the wall clock
+	// time on the client when the event was generated. There is no guarantee that
+	// the client's time is accurate. This field must be formatted in UTC ISO 8601
+	// format, with a trailing 'Z' character, as described publicly on
+	// https://en.wikipedia.org/wiki/ISO_8601#UTC. Note: the number of decimal
+	// seconds digits provided are variable (and unspecified). Consumers should
+	// handle this, i.e. managed code consumers should not use format 'O' for
+	// parsing as it specifies a fixed length. Example:
+	// 2009-06-15T13:45:30.0000000Z.
+	Time time.Time `json:"time"`
+
+	// Sampling rate used in application. This telemetry item represents 1 /
+	// sampleRate actual telemetry items.
+	SampleRate float64 `json:"sampleRate"`
path: telemetry/internal/appinsights/internal/contracts/envelope.go
user: login: dagood
body: This is confusing... this telemetry item isn't an "actual" telemetry item?

(Also, more non-go-idiom starts of comments throughout the file.)
created_at: 2025-05-13T22:31:00Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,167 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package appinsights
+
+import (
+	"bytes"
+	"cmp"
+	"compress/gzip"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"slices"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+type transmitter interface {
+	transmit(ctx context.Context, items []batchItem) (*transmissionResponse, error)
+}
+
+type httpTransmitter struct {
+	endpoint string
+	client   *http.Client
+}
+
+type transmissionResponse struct {
+	statusCode int
+	retryAfter time.Time
+	response   contracts.BackendResponse
+}
+
+const (
+	successResponse                         = http.StatusOK
+	partialSuccessResponse                  = http.StatusPartialContent
+	requestTimeoutResponse                  = http.StatusRequestTimeout
+	tooManyRequestsResponse                 = http.StatusTooManyRequests
+	tooManyRequestsOverExtendedTimeResponse = 439
+	errorResponse                           = http.StatusInternalServerError
+	serviceUnavailableResponse              = http.StatusServiceUnavailable
+)
+
+func newTransmitter(endpointAddress string, client *http.Client) transmitter {
+	if client == nil {
+		client = http.DefaultClient
+	}
+	return &httpTransmitter{endpointAddress, client}
+}
+
+func (transmitter *httpTransmitter) transmit(ctx context.Context, items []batchItem) (*transmissionResponse, error) {
+	// Serialize the items. It could be that some items can't be serialized,
+	// in which case we will skip them and return an error together with the
+	// transmission result.
+	payload, jsonErr := serialize(items)
+	if jsonErr != nil && payload == nil {
+		return nil, jsonErr
+	}
+
+	// Compress the payload
+	var postBody bytes.Buffer
+	gzipWriter := gzip.NewWriter(&postBody)
+	if _, err := gzipWriter.Write(payload); err != nil {
+		gzipWriter.Close()
+		return nil, fmt.Errorf("failed to compress the payload: %v", err)
+	}
+
+	gzipWriter.Close()
path: telemetry/internal/appinsights/transmitter.go
user: login: dagood
body: Not handling `gzipWriter.Close()` error?
created_at: 2025-05-13T22:39:02Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,311 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package appinsights
+
+import (
+	"context"
+	"errors"
+	"log"
+	"net/http"
+	"slices"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+// batchItem is a telemetry item that is sent in a batch.
+type batchItem struct {
+	item    *contracts.Envelope
+	retries int
+}
+
+// inMemoryChannel stores events exclusively in memory.
+// Presently the only telemetry channel implementation available.
+type inMemoryChannel struct {
+	endpointAddr  string
+	batchSize     int
+	batchInterval time.Duration
+	errorLog      *log.Logger
+
+	collectChan chan *contracts.Envelope
+	flushChan   chan struct{}
+	retryChan   chan retryMessage
+
+	transmitter transmitter
+
+	// Use a context instead of a channel to
+	// allow propagating the cancellation to the
+	// transmitter and to the underlying HTTP client.
+	cancelCtx       context.Context
+	cancelCauseFunc context.CancelCauseFunc
+
+	throttled   atomic.Bool
+	closed      atomic.Bool
+	sendQueue   []*[]batchItem
+	sendQueueMu sync.Mutex
+	sendMu      sync.Mutex
+}
+
+type retryMessage struct {
+	throttled  bool
+	retryAfter time.Time
+	items      []batchItem
+}
+
+// newInMemoryChannel creates an inMemoryChannel instance and starts a background submission goroutine.
+func newInMemoryChannel(endpointUrl string, batchSize int, batchInterval time.Duration, httpClient *http.Client, errorLog *log.Logger) *inMemoryChannel {
+	// Set up the channel
+	channel := &inMemoryChannel{
+		endpointAddr:  endpointUrl,
+		batchSize:     batchSize,
+		batchInterval: batchInterval,
+		errorLog:      errorLog,
+		collectChan:   make(chan *contracts.Envelope),
+		flushChan:     make(chan struct{}),
+		retryChan:     make(chan retryMessage),
+		transmitter:   newTransmitter(endpointUrl, httpClient),
+	}
+	channel.cancelCtx, channel.cancelCauseFunc = context.WithCancelCause(context.Background())
+	return channel
+}
+
+func (channel *inMemoryChannel) logf(format string, args ...any) {
+	if channel.errorLog != nil {
+		channel.errorLog.Printf(format, args...)
+	} else {
+		log.Printf(format, args...)
+	}
+}
+
+// Queues a single telemetry item
+func (channel *inMemoryChannel) send(item *contracts.Envelope) {
+	if item != nil && !channel.closed.Load() {
+		channel.collectChan <- item
+	}
+}
+
+// Forces the current queue to be sent
+func (channel *inMemoryChannel) flush() {
+	if channel.closed.Load() {
+		return
+	}
+	channel.flushChan <- struct{}{}
+}
+
+func (channel *inMemoryChannel) retry(throttled bool, retryAfter time.Time, items []batchItem) {
+	// Retry even if the channel is closed to allow for
+	// retrying items that were already sent.
+	channel.retryChan <- retryMessage{throttled, retryAfter, items}
+}
+
+var errStopped = errors.New("client stopped")
+var errClosed = errors.New("client closed")
+
+func (channel *inMemoryChannel) stop() {
+	if channel.closed.Load() {
+		return
+	}
+
+	channel.closed.Store(true)
+	channel.cancelCauseFunc(errStopped)
+}
+
+// Flushes and tears down the submission goroutine and closes internal
+// channels.  Returns a channel that is closed when all pending telemetry
+// items have been submitted and it is safe to shut down without losing
+// telemetry.
+func (channel *inMemoryChannel) close(ctx context.Context) {
+	if channel.closed.Load() {
+		return
+	}
+
+	channel.closed.Store(true)
+	channel.flushChan <- struct{}{}
+	select {
+	case <-ctx.Done():
+		channel.cancelCauseFunc(context.Cause(ctx))
+	case <-channel.cancelCtx.Done():
+		// Successfully flushed
+	}
+}
+
+func (channel *inMemoryChannel) acceptLoop() {
+	channel.start()
+}
+
+// Part of channel accept loop: Initialize buffer and accept first message, handle controls.
+func (channel *inMemoryChannel) start() {
+	items := make([]batchItem, 0, channel.batchSize)
+	timer := time.NewTimer(time.Hour)
+	timer.Stop() // Stop timer until we need it.
+	var dropped int
+	for {
+		select {
+		case item := <-channel.collectChan:
+			if item == nil {
+				panic("received nil event")
+			}
+			if channel.throttled.Load() {
+				// Check if there is space to add the item to the batch.
+				// If not, then drop the event.
+				if len(items) < channel.batchSize {
+					items = append(items, batchItem{item, 0})
+				} else {
+					dropped++
+				}
+				continue
+			}
+			items = append(items, batchItem{item, 0})
+			if len(items) >= channel.batchSize {
+				timer.Stop()
+				channel.sendBatch(items)
+				items = items[:0]
+			} else if len(items) == 1 {
+				// Start the timer if this is the first item in the batch.
+				timer.Reset(channel.batchInterval)
+			}
+
+		case <-channel.flushChan:
+			if channel.throttled.Load() {
+				// Ignore the flush request if we are throttled.
+				continue
+			}
+			timer.Stop()
+			channel.sendBatch(items)
+			items = items[:0]
+
+		case <-timer.C:
+			if channel.throttled.Load() {
+				// When throttled, the timer is reset to the retry time,
+				// so if we get here, then we're no longer throttled.
+				channel.throttled.Store(false)
+				if dropped > 0 {
+					channel.logf("Dropped %d telemetry items due to throttling", dropped)
+					dropped = 0
+				}
+			}
+			channel.sendBatch(items)
+			items = items[:0]
+
+		case msg := <-channel.retryChan:
+			// If there is not enough space in the batch, drop the items.
+			space := channel.batchSize - len(items)
+			if space < len(msg.items) {
+				dropped += len(msg.items) - space
+				msg.items = msg.items[:space]
+			}
+			items = append(items, msg.items...)
+
+			if msg.throttled {
+				channel.throttled.Store(true)
+			}
+			if msg.retryAfter.IsZero() {
+				// If the retry time is not set, use the default batch interval.
+				timer.Reset(channel.batchInterval)
+			} else {
+				timer.Reset(time.Until(msg.retryAfter))
+			}
+
+		case <-channel.cancelCtx.Done():
+			// This is the only path to exit the loop.
+			timer.Stop()
+			return
+		}
+	}
+}
+
+var itemsBuf = sync.Pool{
+	New: func() any {
+		buf := make([]batchItem, 0, 1024)
+		return &buf
+	},
+}
+
+// sendBatch schedules a batch of items for transmission.
+func (channel *inMemoryChannel) sendBatch(items []batchItem) {
+	channel.sendQueueMu.Lock()
+	defer channel.sendQueueMu.Unlock()
+
+	if len(items) == 0 {
+		// Cancel the accept loop if we are closed and the queue is empty.
+		if len(channel.sendQueue) == 0 && channel.closed.Load() {
+			channel.cancelCauseFunc(errClosed)
+		}
+		return
+	}
+
+	// Copy the items to a temporary buffer to let the caller
+	// reuse the item slice.
+	buf := itemsBuf.Get().(*[]batchItem)
+	*buf = (*buf)[:0]
+	*buf = append(*buf, items...)
path: telemetry/internal/appinsights/inmemorychannel.go
user: login: qmuntal
body: Good point. The `items` size is capped to `Client.MaxBatchSize` (defaults to 1024), so the polled buffer sizes can't grow unbounded and cause an OOM, which is the main complain in that issue. I've added a comment to note this limit, and also moved the pool from being a global variable to being a property of the channel so that we can allocate buffers knowing for sure the maximum capacity.
created_at: 2025-05-14T07:08:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,105 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package contracts
+
+import (
+	"errors"
+	"time"
+)
+
+// Data struct to contain both B and C sections.
+type Data struct {
+	BaseType string    `json:"baseType"`
+	BaseData EventData `json:"baseData"`
+}
+
+// Instances of Event represent structured event records that can be grouped
+// and searched by their properties. Event data item also creates a metric of
+// event count by name.
+type EventData struct {
+	// Schema version
+	Ver int `json:"ver"`
+
+	// Event name. Keep it low cardinality to allow proper grouping and useful
+	// metrics.
+	Name string `json:"name"`
+}
+
+// System variables for a telemetry item.
+type Envelope struct {
+	// Envelope version. For internal use only. By assigning this the default, it
+	// will not be serialized within the payload unless changed to a value other
+	// than #1.
+	Ver int `json:"ver"`
+
+	// Type name of telemetry data item.
+	Name string `json:"name"`
+
+	// Event date time when telemetry item was created. This is the wall clock
+	// time on the client when the event was generated. There is no guarantee that
+	// the client's time is accurate. This field must be formatted in UTC ISO 8601
+	// format, with a trailing 'Z' character, as described publicly on
+	// https://en.wikipedia.org/wiki/ISO_8601#UTC. Note: the number of decimal
+	// seconds digits provided are variable (and unspecified). Consumers should
+	// handle this, i.e. managed code consumers should not use format 'O' for
+	// parsing as it specifies a fixed length. Example:
+	// 2009-06-15T13:45:30.0000000Z.
+	Time time.Time `json:"time"`
+
+	// Sampling rate used in application. This telemetry item represents 1 /
+	// sampleRate actual telemetry items.
+	SampleRate float64 `json:"sampleRate"`
path: telemetry/internal/appinsights/internal/contracts/envelope.go
user: login: qmuntal
body: I've fixed the non-idiomatic comments. About the comment, this comes directly from the API documentation, and is the same in other sdks, so I wouldn't touch it. The phrase is confusing, but I think it refers to the fact that a single envelope can be the result of sampling multiple items. In fact I might be able to use this to implement `Event.Add` where `n` is higher than 1. I'll give it a try once I can upload telemetry to a dev appinsights endpoint.
created_at: 2025-05-14T07:16:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,167 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package appinsights
+
+import (
+	"bytes"
+	"cmp"
+	"compress/gzip"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"slices"
+	"time"
+
+	"github.com/microsoft/go-infra/telemetry/internal/appinsights/internal/contracts"
+)
+
+type transmitter interface {
+	transmit(ctx context.Context, items []batchItem) (*transmissionResponse, error)
+}
+
+type httpTransmitter struct {
+	endpoint string
+	client   *http.Client
+}
+
+type transmissionResponse struct {
+	statusCode int
+	retryAfter time.Time
+	response   contracts.BackendResponse
+}
+
+const (
+	successResponse                         = http.StatusOK
+	partialSuccessResponse                  = http.StatusPartialContent
+	requestTimeoutResponse                  = http.StatusRequestTimeout
+	tooManyRequestsResponse                 = http.StatusTooManyRequests
+	tooManyRequestsOverExtendedTimeResponse = 439
+	errorResponse                           = http.StatusInternalServerError
+	serviceUnavailableResponse              = http.StatusServiceUnavailable
+)
+
+func newTransmitter(endpointAddress string, client *http.Client) transmitter {
+	if client == nil {
+		client = http.DefaultClient
+	}
+	return &httpTransmitter{endpointAddress, client}
+}
+
+func (transmitter *httpTransmitter) transmit(ctx context.Context, items []batchItem) (*transmissionResponse, error) {
+	// Serialize the items. It could be that some items can't be serialized,
+	// in which case we will skip them and return an error together with the
+	// transmission result.
+	payload, jsonErr := serialize(items)
+	if jsonErr != nil && payload == nil {
+		return nil, jsonErr
+	}
+
+	// Compress the payload
+	var postBody bytes.Buffer
+	gzipWriter := gzip.NewWriter(&postBody)
+	if _, err := gzipWriter.Write(payload); err != nil {
+		gzipWriter.Close()
+		return nil, fmt.Errorf("failed to compress the payload: %v", err)
+	}
+
+	gzipWriter.Close()
path: telemetry/internal/appinsights/transmitter.go
user: login: qmuntal
body: Good catch. Fixed.
created_at: 2025-05-14T07:17:29Z
author_association: MEMBER

PR comments:
user: login: qmuntal
created_at: 2025-05-05T13:24:12Z
body: I've bumped the Ubuntu image to 22.04, as 20.04 is going to be removed soon and it is failing now (see https://github.com/actions/runner-images/issues/11101).
author_association: MEMBER
user: login: qmuntal
created_at: 2025-05-08T14:58:29Z
body: This PR is ready for review!
author_association: MEMBER