├── CODEOWNERS ├── .gitignore ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── ci.yml │ └── apm-project.yml ├── README.md ├── Makefile ├── tools ├── go.mod ├── tools.go └── go.sum ├── pkg ├── apmclient │ ├── doc.go │ ├── option.go │ ├── types.go │ ├── config.go │ └── client.go ├── metricgen │ ├── doc.go │ ├── stats.go │ ├── intake_test.go │ ├── otlp_test.go │ ├── intake.go │ ├── config.go │ └── otlp.go ├── tracegen │ ├── util.go │ ├── stats.go │ ├── distributed.go │ ├── intake.go │ ├── config.go │ └── otlp.go ├── espoll │ ├── request.go │ ├── query.go │ ├── client.go │ └── search.go └── approvaltest │ ├── sort.go │ └── approvals.go ├── cmd ├── apmtool │ ├── commands.go │ ├── services.go │ ├── env.go │ ├── main.go │ ├── events.go │ ├── tracegen.go │ ├── cache.go │ ├── sourcemap.go │ ├── credentials.go │ └── espoll.go ├── flatten-approvals │ └── main.go └── check-approvals │ └── main.go ├── go.mod ├── LICENSE.txt └── go.sum /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @elastic/obs-ds-intake-services 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .vscode 3 | .idea/ 4 | 5 | *.swp 6 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | /.github/workflows @elastic/observablt-ci -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # APM Tools 2 | 3 | A collection of tools for Elastic APM to perform various tasks. 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GO_TEST_TIMEOUT=30s 2 | 3 | fmt: tools/go.mod 4 | @go run -modfile=tools/go.mod github.com/elastic/go-licenser . 5 | @go run -modfile=tools/go.mod golang.org/x/tools/cmd/goimports -local github.com/elastic/ -w . 6 | 7 | lint: tools/go.mod 8 | go run -modfile=tools/go.mod honnef.co/go/tools/cmd/staticcheck -checks=all ./... 9 | go mod tidy && git diff --exit-code 10 | 11 | .PHONY: test 12 | test: go.mod 13 | go test -race -v -timeout=$(GO_TEST_TIMEOUT) ./... 14 | 15 | .PHONY: integration-test 16 | integration-test: 17 | go test --tags=integration ./... 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # go dependencies 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | groups: 9 | otel: 10 | patterns: 11 | - "go.opentelemetry.io/*" 12 | go-agent: 13 | patterns: 14 | - "go.elastic.co/apm*" 15 | # go tools 16 | - package-ecosystem: "gomod" 17 | directory: "tools/" 18 | schedule: 19 | interval: "weekly" 20 | 21 | # GitHub actions 22 | - package-ecosystem: "github-actions" 23 | directory: "/" 24 | schedule: 25 | interval: "weekly" 26 | day: "sunday" 27 | time: "22:00" 28 | groups: 29 | github-actions: 30 | patterns: 31 | - "*" 32 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: ["push", "pull_request"] 3 | 4 | permissions: 5 | contents: read 6 | 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v6 12 | - uses: actions/setup-go@v6 13 | with: 14 | go-version-file: go.mod 15 | cache: true 16 | - run: make lint 17 | - run: make fmt 18 | - name: Verify repo is up-to-date 19 | run: | 20 | if [ -n "$(git status --porcelain)" ]; then 21 | echo 'Updates required:' 22 | git status 23 | exit 1 24 | fi 25 | 26 | run-tests: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v6 30 | - uses: actions/setup-go@v6 31 | with: 32 | go-version-file: go.mod 33 | cache: true 34 | - name: Run tests 35 | run: make test 36 | -------------------------------------------------------------------------------- /tools/go.mod: -------------------------------------------------------------------------------- 1 | module go.elastic.co/apm/v2/tools 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/elastic/go-licenser v0.4.2 7 | go.elastic.co/go-licence-detector v0.10.0 8 | golang.org/x/tools v0.40.0 9 | honnef.co/go/tools v0.6.1 10 | ) 11 | 12 | require ( 13 | github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect 14 | github.com/cyphar/filepath-securejoin v0.4.1 // indirect 15 | github.com/google/licenseclassifier v0.0.0-20250213175939-b5d1a3369749 // indirect 16 | github.com/sergi/go-diff v1.4.0 // indirect 17 | golang.org/x/exp/typeparams v0.0.0-20241009180824-f66d83c29e7c // indirect 18 | golang.org/x/mod v0.31.0 // indirect 19 | golang.org/x/sync v0.19.0 // indirect 20 | golang.org/x/sys v0.39.0 // indirect 21 | golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc // indirect 22 | golang.org/x/tools/go/expect v0.1.1-deprecated // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /pkg/apmclient/doc.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | // Package apmclient provides an API for querying APM data. 19 | package apmclient 20 | -------------------------------------------------------------------------------- /pkg/apmclient/option.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package apmclient 19 | 20 | type options struct { 21 | } 22 | 23 | type Option func(*options) 24 | -------------------------------------------------------------------------------- /pkg/metricgen/doc.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | // Package metricgen generates Elastic APM V2 and OTLP data for smoke testing. 19 | package metricgen 20 | -------------------------------------------------------------------------------- /pkg/apmclient/types.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package apmclient 19 | 20 | type APIKey struct { 21 | Encoded string 22 | } 23 | 24 | type ServiceSummary struct { 25 | Name string 26 | Environment string 27 | Agent string 28 | Language string 29 | } 30 | -------------------------------------------------------------------------------- /tools/tools.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | //go:build tools 19 | // +build tools 20 | 21 | package main 22 | 23 | import ( 24 | _ "golang.org/x/tools/cmd/goimports" 25 | _ "honnef.co/go/tools/cmd/staticcheck" 26 | 27 | _ "go.elastic.co/go-licence-detector" 28 | 29 | _ "github.com/elastic/go-licenser" 30 | ) 31 | -------------------------------------------------------------------------------- /cmd/apmtool/commands.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package main 19 | 20 | import ( 21 | "github.com/elastic/apm-tools/pkg/apmclient" 22 | ) 23 | 24 | type Commands struct { 25 | cfg apmclient.Config 26 | } 27 | 28 | func (cmd *Commands) getClient() (*apmclient.Client, error) { 29 | return apmclient.New(cmd.cfg) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/metricgen/stats.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package metricgen 19 | 20 | // EventStats holds client-side stats. 21 | type EventStats struct { 22 | // MetricSent holds the number of metrics events sent. 23 | MetricSent int 24 | } 25 | 26 | // Add adds the statistics together. 27 | func (e *EventStats) Add(metricSent int) { 28 | e.MetricSent += metricSent 29 | } 30 | -------------------------------------------------------------------------------- /pkg/tracegen/util.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package tracegen 19 | 20 | import ( 21 | "encoding/binary" 22 | "math/rand" 23 | 24 | "go.elastic.co/apm/v2" 25 | ) 26 | 27 | // NewRandomTraceID returns randomly generated apm.TraceID 28 | // which is also compatible with otel's trace.TraceID 29 | func NewRandomTraceID() apm.TraceID { 30 | var traceID apm.TraceID 31 | binary.LittleEndian.PutUint64(traceID[:8], rand.Uint64()) 32 | binary.LittleEndian.PutUint64(traceID[8:], rand.Uint64()) 33 | return traceID 34 | } 35 | -------------------------------------------------------------------------------- /pkg/tracegen/stats.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package tracegen 19 | 20 | // EventStats holds client-side stats. 21 | type EventStats struct { 22 | // ExceptionssSent holds the number of exception span events sent. 23 | ExceptionsSent int 24 | 25 | // LogsSent holds the number of logs sent, including standalone 26 | // log records and non-exception span events. 27 | LogsSent int 28 | 29 | // SpansSent holds the number of transactions and spans sent. 30 | SpansSent int 31 | } 32 | 33 | // Add adds the statistics together, returning the result. 34 | func (lhs EventStats) Add(rhs EventStats) EventStats { 35 | return EventStats{ 36 | ExceptionsSent: lhs.ExceptionsSent + rhs.ExceptionsSent, 37 | LogsSent: lhs.LogsSent + rhs.LogsSent, 38 | SpansSent: lhs.SpansSent + rhs.SpansSent, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /cmd/apmtool/services.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package main 19 | 20 | import ( 21 | "context" 22 | "fmt" 23 | 24 | "github.com/urfave/cli/v3" 25 | ) 26 | 27 | func (cmd *Commands) servicesCommand(ctx context.Context, c *cli.Command) error { 28 | client, err := cmd.getClient() 29 | if err != nil { 30 | return err 31 | } 32 | services, err := client.ServiceSummary(ctx) 33 | if err != nil { 34 | return err 35 | } 36 | for _, service := range services { 37 | fmt.Println(service) 38 | } 39 | return nil 40 | } 41 | 42 | // NewListServiceCmd returns pointer to a Command that talks to APM Server and list all APM services 43 | func NewListServiceCmd(commands *Commands) *cli.Command { 44 | return &cli.Command{ 45 | Name: "list-services", 46 | Usage: "list APM services", 47 | Action: commands.servicesCommand, 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /pkg/metricgen/intake_test.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | //go:build integration 19 | // +build integration 20 | 21 | package metricgen_test 22 | 23 | import ( 24 | "context" 25 | "os" 26 | "testing" 27 | 28 | "github.com/stretchr/testify/assert" 29 | "github.com/stretchr/testify/require" 30 | 31 | "github.com/elastic/apm-tools/pkg/metricgen" 32 | ) 33 | 34 | func TestSendIntakeV2(t *testing.T) { 35 | // generate these using tilt + apmtool agent-env 36 | u := os.Getenv("ELASTIC_APM_SERVER_URL") 37 | apiKey := os.Getenv("ELASTIC_APM_API_KEY") 38 | 39 | s, err := metricgen.SendIntakeV2(context.Background(), 40 | metricgen.WithAPMServerURL(u), 41 | metricgen.WithAPIKey(apiKey), 42 | metricgen.WithVerifyServerCert(false), 43 | metricgen.WithElasticAPMServiceName("metricgen_apm_test"), 44 | ) 45 | require.NoError(t, err) 46 | 47 | t.Logf("%+v\n", s) 48 | assert.Equal(t, 2, s.MetricSent) 49 | } 50 | -------------------------------------------------------------------------------- /pkg/tracegen/distributed.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package tracegen 19 | 20 | import ( 21 | "context" 22 | "fmt" 23 | 24 | "go.elastic.co/apm/v2" 25 | ) 26 | 27 | // SendDistributedTrace sends events generated by both APM Go Agent and OTEL library and 28 | // link them with the same traceID so that they are linked and can be shown in the same trace view 29 | func SendDistributedTrace(ctx context.Context, cfg Config) (EventStats, error) { 30 | if err := cfg.validate(); err != nil { 31 | return EventStats{}, err 32 | } 33 | 34 | txCtx, apmStats, err := SendIntakeV2Trace(ctx, cfg) 35 | if err != nil { 36 | return EventStats{}, err 37 | } 38 | 39 | traceparent := formatTraceparentHeader(txCtx) 40 | tracestate := txCtx.State.String() 41 | ctx = SetOTLPTracePropagator(ctx, traceparent, tracestate) 42 | 43 | otlpStats, err := SendOTLPTrace(ctx, cfg) 44 | if err != nil { 45 | return EventStats{}, err 46 | } 47 | return apmStats.Add(otlpStats), nil 48 | } 49 | 50 | func formatTraceparentHeader(c apm.TraceContext) string { 51 | return fmt.Sprintf("%02x-%032x-%016x-%02x", 0, c.Trace[:], c.Span[:], c.Options) 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/apm-project.yml: -------------------------------------------------------------------------------- 1 | name: Add to Project 2 | on: 3 | issues: 4 | types: 5 | - opened 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | add_to_project: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Get token 14 | id: get_token 15 | uses: actions/create-github-app-token@v2 16 | with: 17 | app-id: ${{ secrets.OBS_AUTOMATION_APP_ID }} 18 | private-key: ${{ secrets.OBS_AUTOMATION_APP_PEM }} 19 | permission-organization-projects: write 20 | permission-issues: read 21 | 22 | - uses: octokit/graphql-action@v2.x 23 | id: add_to_project 24 | with: 25 | query: | 26 | mutation add_to_project($projectid:ID!,$contentid:ID!) { 27 | addProjectV2ItemById(input:{projectId:$projectid contentId:$contentid}) { 28 | item { 29 | ... on ProjectV2Item { 30 | id 31 | } 32 | } 33 | } 34 | } 35 | projectid: ${{ env.PROJECT_ID }} 36 | contentid: ${{ github.event.issue.node_id }} 37 | env: 38 | PROJECT_ID: "PVT_kwDOAGc3Zs0VSg" 39 | GITHUB_TOKEN: ${{ steps.get_token.outputs.token }} 40 | - uses: octokit/graphql-action@v2.x 41 | id: label_team 42 | with: 43 | query: | 44 | mutation label_team($projectid:ID!,$itemid:ID!,$fieldid:ID!,$value:String!) { 45 | updateProjectV2ItemFieldValue(input: { projectId:$projectid itemId:$itemid fieldId:$fieldid value:{singleSelectOptionId: $value} }) { 46 | projectV2Item { 47 | id 48 | content { 49 | ... on Issue { 50 | number 51 | } 52 | } 53 | } 54 | } 55 | } 56 | projectid: ${{ env.PROJECT_ID }} 57 | itemid: ${{ fromJSON(steps.add_to_project.outputs.data).addProjectV2ItemById.item.id }} 58 | fieldid: "PVTSSF_lADOAGc3Zs0VSs2scg" 59 | value: "6c538d8a" 60 | env: 61 | PROJECT_ID: "PVT_kwDOAGc3Zs0VSg" 62 | GITHUB_TOKEN: ${{ steps.get_token.outputs.token }} 63 | -------------------------------------------------------------------------------- /pkg/metricgen/otlp_test.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | //go:build integration 19 | // +build integration 20 | 21 | package metricgen_test 22 | 23 | import ( 24 | "context" 25 | "os" 26 | "testing" 27 | 28 | "github.com/stretchr/testify/assert" 29 | "github.com/stretchr/testify/require" 30 | 31 | "github.com/elastic/apm-tools/pkg/metricgen" 32 | ) 33 | 34 | func TestSendOTLP_http(t *testing.T) { 35 | // generate these using tilt + apmtool agent-env 36 | u := os.Getenv("ELASTIC_APM_SERVER_URL") 37 | apiKey := os.Getenv("ELASTIC_APM_API_KEY") 38 | 39 | s, err := metricgen.SendOTLP(context.Background(), 40 | metricgen.WithAPMServerURL(u), 41 | metricgen.WithAPIKey(apiKey), 42 | metricgen.WithVerifyServerCert(false), 43 | metricgen.WithOTLPServiceName("metricgen_otlp_test"), 44 | metricgen.WithOTLPProtocol("http/protobuf"), 45 | ) 46 | require.NoError(t, err) 47 | 48 | t.Logf("%+v\n", s) 49 | assert.Equal(t, 1, s.MetricSent) 50 | } 51 | 52 | func TestSendOTLP_grpc(t *testing.T) { 53 | // generate these using tilt + apmtool agent-env 54 | u := os.Getenv("ELASTIC_APM_SERVER_URL") 55 | apiKey := os.Getenv("ELASTIC_APM_API_KEY") 56 | 57 | s, err := metricgen.SendOTLP(context.Background(), 58 | metricgen.WithAPMServerURL(u), 59 | metricgen.WithAPIKey(apiKey), 60 | metricgen.WithVerifyServerCert(false), 61 | metricgen.WithOTLPServiceName("metricgen_otlp_test"), 62 | metricgen.WithOTLPProtocol("grpc"), 63 | ) 64 | require.NoError(t, err) 65 | 66 | t.Logf("%+v\n", s) 67 | assert.Equal(t, 1, s.MetricSent) 68 | } 69 | -------------------------------------------------------------------------------- /pkg/espoll/request.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package espoll 19 | 20 | import ( 21 | "bytes" 22 | "io" 23 | "net/http" 24 | 25 | "github.com/elastic/go-elasticsearch/v8/esapi" 26 | ) 27 | 28 | // bodyRepeater wraps an esapi.Transport, copying the request body on the first 29 | // call to Perform as needed, and replacing the request body on subsequent calls. 30 | type bodyRepeater struct { 31 | t esapi.Transport 32 | getBody func() (io.ReadCloser, error) 33 | } 34 | 35 | func (br *bodyRepeater) Perform(req *http.Request) (*http.Response, error) { 36 | if req.Body == nil { 37 | return br.t.Perform(req) 38 | } 39 | if br.getBody == nil { 40 | // First call to Perform: set br.getBody to req.GetBody 41 | // (if non-nil), or otherwise by replacing req.Body with 42 | // a TeeReader that reads into a buffer, and setting 43 | // getBody to a function that returns that buffer on 44 | // subsequent calls. 45 | br.getBody = req.GetBody 46 | if br.getBody == nil { 47 | // no GetBody, gotta copy it ourselves 48 | var buf bytes.Buffer 49 | req.Body = readCloser{ 50 | Reader: io.TeeReader(req.Body, &buf), 51 | Closer: req.Body, 52 | } 53 | br.getBody = func() (io.ReadCloser, error) { 54 | r := bytes.NewReader(buf.Bytes()) 55 | return io.NopCloser(r), nil 56 | } 57 | } 58 | } else { 59 | body, err := br.getBody() 60 | if err != nil { 61 | return nil, err 62 | } 63 | req.Body.Close() 64 | req.Body = body 65 | } 66 | return br.t.Perform(req) 67 | } 68 | 69 | type readCloser struct { 70 | io.Reader 71 | io.Closer 72 | } 73 | -------------------------------------------------------------------------------- /cmd/apmtool/env.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package main 19 | 20 | import ( 21 | "context" 22 | "fmt" 23 | 24 | "github.com/urfave/cli/v3" 25 | ) 26 | 27 | func (cmd *Commands) envCommand(ctx context.Context, c *cli.Command) error { 28 | if cmd.cfg.ElasticsearchURL == "" { 29 | return fmt.Errorf("elasticsearch URL is not set") 30 | } 31 | if cmd.cfg.Username == "" { 32 | return fmt.Errorf("elasticsearch username is not set") 33 | } 34 | if cmd.cfg.Password == "" { 35 | return fmt.Errorf("elasticsearch password is not set") 36 | } 37 | 38 | creds, err := cmd.getCredentials(ctx, c) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | fmt.Printf("export ELASTIC_APM_SERVER_URL=%q;\n", cmd.cfg.APMServerURL) 44 | if creds.APIKey != "" { 45 | fmt.Printf("export ELASTIC_APM_API_KEY=%q;\n", creds.APIKey) 46 | } else if creds.SecretToken != "" { 47 | fmt.Printf("export ELASTIC_APM_SECRET_TOKEN=%q;\n", creds.SecretToken) 48 | } 49 | 50 | fmt.Printf("export OTEL_EXPORTER_OTLP_ENDPOINT=%q;\n", cmd.cfg.APMServerURL) 51 | if creds.APIKey != "" { 52 | fmt.Printf("export OTEL_EXPORTER_OTLP_HEADERS=%q;\n", "Authorization=ApiKey "+creds.APIKey) 53 | } else if creds.SecretToken != "" { 54 | fmt.Printf("export OTEL_EXPORTER_OTLP_HEADERS=%q;\n", "Authorization=Bearer "+creds.SecretToken) 55 | } 56 | return nil 57 | } 58 | 59 | // NewPrintEnvCmd returns pointer to a Command that prints environment variables for configuring Elastic APM agent 60 | func NewPrintEnvCmd(commands *Commands) *cli.Command { 61 | return &cli.Command{ 62 | Name: "agent-env", 63 | Usage: "print environment variables for configuring Elastic APM agents", 64 | Action: commands.envCommand, 65 | Flags: []cli.Flag{ 66 | &cli.DurationFlag{ 67 | Name: "api-key-expiration", 68 | Usage: "specify how long before a created API Key expires. 0 means it never expires.", 69 | }, 70 | }, 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /pkg/espoll/query.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package espoll 19 | 20 | import "encoding/json" 21 | 22 | type BoolQuery struct { 23 | Filter []any 24 | Must []any 25 | MustNot []any 26 | Should []any 27 | MinimumShouldMatch int 28 | Boost float64 29 | } 30 | 31 | func (q BoolQuery) MarshalJSON() ([]byte, error) { 32 | type boolQuery struct { 33 | Filter []any `json:"filter,omitempty"` 34 | Must []any `json:"must,omitempty"` 35 | MustNot []any `json:"must_not,omitempty"` 36 | Should []any `json:"should,omitempty"` 37 | MinimumShouldMatch int `json:"minimum_should_match,omitempty"` 38 | Boost float64 `json:"boost,omitempty"` 39 | } 40 | return encodeQueryJSON("bool", boolQuery(q)) 41 | } 42 | 43 | type ExistsQuery struct { 44 | Field string 45 | } 46 | 47 | func (q ExistsQuery) MarshalJSON() ([]byte, error) { 48 | return encodeQueryJSON("exists", map[string]any{ 49 | "field": q.Field, 50 | }) 51 | } 52 | 53 | type TermQuery struct { 54 | Field string 55 | Value any 56 | Boost float64 57 | } 58 | 59 | func (q TermQuery) MarshalJSON() ([]byte, error) { 60 | type termQuery struct { 61 | Value any `json:"value"` 62 | Boost float64 `json:"boost,omitempty"` 63 | } 64 | return encodeQueryJSON("term", map[string]any{ 65 | q.Field: termQuery{q.Value, q.Boost}, 66 | }) 67 | } 68 | 69 | type TermsQuery struct { 70 | Field string 71 | Values []any 72 | Boost float64 73 | } 74 | 75 | func (q TermsQuery) MarshalJSON() ([]byte, error) { 76 | args := map[string]any{ 77 | q.Field: q.Values, 78 | } 79 | if q.Boost != 0 { 80 | args["boost"] = q.Boost 81 | } 82 | return encodeQueryJSON("terms", args) 83 | } 84 | 85 | type MatchPhraseQuery struct { 86 | Field string 87 | Value any 88 | } 89 | 90 | func (q MatchPhraseQuery) MarshalJSON() ([]byte, error) { 91 | return encodeQueryJSON("match_phrase", map[string]any{ 92 | q.Field: q.Value, 93 | }) 94 | } 95 | 96 | func encodeQueryJSON(k string, v any) ([]byte, error) { 97 | m := map[string]any{k: v} 98 | return json.Marshal(m) 99 | } 100 | -------------------------------------------------------------------------------- /pkg/metricgen/intake.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package metricgen 19 | 20 | import ( 21 | "context" 22 | "fmt" 23 | "os" 24 | "strconv" 25 | 26 | "go.elastic.co/apm/module/apmotel/v2" 27 | "go.elastic.co/apm/v2" 28 | "go.opentelemetry.io/otel/sdk/metric" 29 | ) 30 | 31 | // SendIntakeV2 sends specific metrics to the configured Elastic APM intake V2. 32 | // 33 | // Metrics sent are: 34 | // - apm(float64, value=1.0); gathered from a apm.MetricGatherer 35 | // - apmotel(float64, value=1.0); gathered from a otel MeterProvider through apmotel bridge 36 | // All builtin APM Agent metrics have been disabled. 37 | func SendIntakeV2(_ context.Context, opts ...ConfigOption) (EventStats, error) { 38 | cfg := newConfig(opts...) 39 | if err := cfg.Validate(); err != nil { 40 | return EventStats{}, fmt.Errorf("cannot validate IntakeV2 Metrics configuration: %w", err) 41 | } 42 | 43 | // disable builtin metrics entirely to have predictable metrics value. 44 | os.Setenv("ELASTIC_APM_DISABLE_METRICS", "system.*, *cpu*, *golang*") 45 | os.Setenv("ELASTIC_APM_VERIFY_SERVER_CERT", strconv.FormatBool(cfg.verifyServerCert)) 46 | 47 | stats := EventStats{} 48 | 49 | tracer, err := apm.NewTracer(cfg.apmServiceName, "0.0.1") 50 | if err != nil { 51 | return EventStats{}, fmt.Errorf("cannot setup a tracer: %w", err) 52 | } 53 | 54 | // setup apmotel bridge to test metrics coming from OTLP 55 | exporter, err := apmotel.NewGatherer() 56 | if err != nil { 57 | return EventStats{}, fmt.Errorf("cannot init gatherer: %w", err) 58 | } 59 | 60 | provider := metric.NewMeterProvider(metric.WithReader(exporter)) 61 | o := tracer.RegisterMetricsGatherer(exporter) 62 | defer o() 63 | 64 | d := tracer.RegisterMetricsGatherer(Gatherer{}) 65 | defer d() 66 | 67 | meter := provider.Meter("metricgen") 68 | counter, _ := meter.Float64Counter("apmotel") 69 | counter.Add(context.Background(), 1) 70 | stats.Add(1) 71 | 72 | tracer.SendMetrics(nil) 73 | stats.Add(1) 74 | 75 | tracer.Flush(nil) 76 | 77 | return stats, nil 78 | } 79 | 80 | type Gatherer struct { 81 | } 82 | 83 | // GatherMetrics gathers metrics into out. 84 | func (e Gatherer) GatherMetrics(ctx context.Context, out *apm.Metrics) error { 85 | out.Add("apm", nil, 1.0) 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/elastic/apm-tools 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/elastic/go-elasticsearch/v8 v8.19.1 7 | github.com/fatih/color v1.18.0 8 | github.com/gofrs/flock v0.13.0 9 | github.com/google/go-cmp v0.7.0 10 | github.com/stretchr/testify v1.11.1 11 | github.com/tidwall/gjson v1.18.0 12 | github.com/tidwall/sjson v1.2.5 13 | github.com/urfave/cli/v3 v3.6.1 14 | go.elastic.co/apm/module/apmotel/v2 v2.7.2 15 | go.elastic.co/apm/v2 v2.7.2 16 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 17 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0 18 | go.opentelemetry.io/otel/metric v1.39.0 19 | go.opentelemetry.io/otel/sdk/metric v1.39.0 20 | google.golang.org/grpc v1.78.0 21 | ) 22 | 23 | require ( 24 | github.com/cenkalti/backoff/v5 v5.0.3 // indirect 25 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 26 | github.com/davecgh/go-spew v1.1.1 // indirect 27 | github.com/google/uuid v1.6.0 // indirect 28 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect 29 | github.com/hashicorp/go-version v1.8.0 // indirect 30 | github.com/json-iterator/go v1.1.12 // indirect 31 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 32 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect 33 | github.com/pmezard/go-difflib v1.0.0 // indirect 34 | go.elastic.co/apm/module/apmhttp/v2 v2.7.2 // indirect 35 | go.opentelemetry.io/auto/sdk v1.2.1 // indirect 36 | go.opentelemetry.io/collector/featuregate v1.48.0 // indirect 37 | go.opentelemetry.io/proto/otlp v1.9.0 // indirect 38 | go.uber.org/multierr v1.11.0 // indirect 39 | golang.org/x/net v0.47.0 // indirect 40 | golang.org/x/text v0.31.0 // indirect 41 | google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect 42 | google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect 43 | google.golang.org/protobuf v1.36.10 // indirect 44 | gopkg.in/yaml.v3 v3.0.1 // indirect 45 | ) 46 | 47 | require ( 48 | github.com/armon/go-radix v1.0.0 // indirect 49 | github.com/elastic/elastic-transport-go/v8 v8.8.0 // indirect 50 | github.com/elastic/go-sysinfo v1.15.0 // indirect 51 | github.com/elastic/go-windows v1.0.2 // indirect 52 | github.com/go-logr/logr v1.4.3 // indirect 53 | github.com/go-logr/stdr v1.2.2 // indirect 54 | github.com/mattn/go-colorable v0.1.13 // indirect 55 | github.com/mattn/go-isatty v0.0.20 // indirect 56 | github.com/pkg/errors v0.9.1 // indirect 57 | github.com/prometheus/procfs v0.15.1 // indirect 58 | github.com/tidwall/match v1.1.1 // indirect 59 | github.com/tidwall/pretty v1.2.1 // indirect 60 | go.elastic.co/fastjson v1.5.1 // indirect 61 | go.opentelemetry.io/collector/pdata v1.48.0 62 | go.opentelemetry.io/otel v1.39.0 63 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 64 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 65 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 66 | go.opentelemetry.io/otel/sdk v1.39.0 67 | go.opentelemetry.io/otel/trace v1.39.0 68 | golang.org/x/sys v0.39.0 // indirect 69 | howett.net/plist v1.0.1 // indirect 70 | ) 71 | -------------------------------------------------------------------------------- /cmd/flatten-approvals/main.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package main 19 | 20 | import ( 21 | "encoding/json" 22 | "flag" 23 | "fmt" 24 | "io" 25 | "log" 26 | "os" 27 | "path/filepath" 28 | ) 29 | 30 | var inplace = flag.Bool("i", false, "modify file in place") 31 | 32 | func main() { 33 | flag.Parse() 34 | if err := flatten(flag.Args()); err != nil { 35 | log.Fatal(err) 36 | } 37 | } 38 | 39 | func flatten(args []string) error { 40 | var filepaths []string 41 | for _, arg := range args { 42 | matches, err := filepath.Glob(arg) 43 | if err != nil { 44 | return err 45 | } 46 | filepaths = append(filepaths, matches...) 47 | } 48 | for _, filepath := range filepaths { 49 | if err := transform(filepath); err != nil { 50 | return fmt.Errorf("error transforming %q: %w", filepath, err) 51 | } 52 | } 53 | return nil 54 | } 55 | 56 | // transform []{"events": {"object": {"field": ...}}} to []{"field", "field", ...} 57 | func transform(filepath string) error { 58 | var input struct { 59 | Events []map[string]any `json:"events"` 60 | } 61 | if err := decodeJSONFile(filepath, &input); err != nil { 62 | return fmt.Errorf("could not read existing approved events file: %w", err) 63 | } 64 | out := make([]map[string][]any, 0, len(input.Events)) 65 | for _, event := range input.Events { 66 | fields := make(map[string][]any) 67 | flattenFields("", event, fields) 68 | out = append(out, fields) 69 | } 70 | 71 | var w io.Writer = os.Stdout 72 | if *inplace { 73 | f, err := os.Create(filepath) 74 | if err != nil { 75 | return err 76 | } 77 | defer f.Close() 78 | w = f 79 | } 80 | enc := json.NewEncoder(w) 81 | enc.SetIndent("", " ") 82 | return enc.Encode(out) 83 | } 84 | 85 | func flattenFields(k string, v any, out map[string][]any) { 86 | switch v := v.(type) { 87 | case map[string]any: 88 | for k2, v := range v { 89 | if k != "" { 90 | k2 = k + "." + k2 91 | } 92 | flattenFields(k2, v, out) 93 | } 94 | case []any: 95 | for _, v := range v { 96 | flattenFields(k, v, out) 97 | } 98 | default: 99 | out[k] = append(out[k], v) 100 | } 101 | } 102 | 103 | func decodeJSONFile(path string, out interface{}) error { 104 | f, err := os.Open(path) 105 | if err != nil { 106 | return err 107 | } 108 | defer f.Close() 109 | if err := json.NewDecoder(f).Decode(&out); err != nil { 110 | return fmt.Errorf("cannot unmarshal file %q: %w", path, err) 111 | } 112 | return nil 113 | } 114 | -------------------------------------------------------------------------------- /cmd/check-approvals/main.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package main 19 | 20 | import ( 21 | "bufio" 22 | "encoding/json" 23 | "fmt" 24 | "os" 25 | "path/filepath" 26 | "strings" 27 | 28 | "github.com/fatih/color" 29 | "github.com/google/go-cmp/cmp" 30 | 31 | "github.com/elastic/apm-tools/pkg/approvaltest" 32 | ) 33 | 34 | func main() { 35 | os.Exit(approval()) 36 | } 37 | 38 | func approval() int { 39 | cwd, _ := os.Getwd() 40 | receivedFiles := findFiles(cwd, approvaltest.ReceivedSuffix) 41 | 42 | for _, rf := range receivedFiles { 43 | af := strings.TrimSuffix(rf, approvaltest.ReceivedSuffix) + approvaltest.ApprovedSuffix 44 | 45 | var approved, received interface{} 46 | if err := decodeJSONFile(rf, &received); err != nil { 47 | fmt.Println("Could not create diff ", err) 48 | return 3 49 | } 50 | if err := decodeJSONFile(af, &approved); err != nil && !os.IsNotExist(err) { 51 | fmt.Println("Could not create diff ", err) 52 | return 3 53 | } 54 | 55 | diff := cmp.Diff(approved, received) 56 | added := color.New(color.FgBlack, color.BgGreen).SprintFunc() 57 | deleted := color.New(color.FgBlack, color.BgRed).SprintFunc() 58 | scanner := bufio.NewScanner(strings.NewReader(diff)) 59 | for scanner.Scan() { 60 | line := scanner.Text() 61 | if len(line) > 0 { 62 | switch line[0] { 63 | case '-': 64 | line = deleted(line) 65 | case '+': 66 | line = added(line) 67 | } 68 | } 69 | fmt.Println(line) 70 | } 71 | 72 | fmt.Println(rf) 73 | fmt.Println("\nApprove Changes? (y/n)") 74 | reader := bufio.NewReader(os.Stdin) 75 | input, _, _ := reader.ReadRune() 76 | switch input { 77 | case 'y': 78 | approvedPath := strings.Replace(rf, approvaltest.ReceivedSuffix, approvaltest.ApprovedSuffix, 1) 79 | os.Rename(rf, approvedPath) 80 | } 81 | } 82 | return 0 83 | } 84 | 85 | func findFiles(rootDir string, suffix string) []string { 86 | files := []string{} 87 | filepath.Walk(rootDir, func(path string, _ os.FileInfo, _ error) error { 88 | if strings.HasSuffix(path, suffix) { 89 | files = append(files, path) 90 | } 91 | return nil 92 | }) 93 | return files 94 | } 95 | 96 | func decodeJSONFile(path string, out interface{}) error { 97 | f, err := os.Open(path) 98 | if err != nil { 99 | return err 100 | } 101 | defer f.Close() 102 | if err := json.NewDecoder(f).Decode(&out); err != nil { 103 | return fmt.Errorf("cannot unmarshal file %q: %w", path, err) 104 | } 105 | return nil 106 | } 107 | -------------------------------------------------------------------------------- /cmd/apmtool/main.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package main 19 | 20 | import ( 21 | "context" 22 | "log" 23 | "os" 24 | 25 | "github.com/urfave/cli/v3" 26 | ) 27 | 28 | func main() { 29 | commands := &Commands{} 30 | cmd := &cli.Command{ 31 | Flags: []cli.Flag{ 32 | &cli.BoolFlag{ 33 | Name: "verbose", 34 | Usage: "print debugging messages about progress", 35 | Aliases: []string{"v"}, 36 | }, 37 | &cli.StringFlag{ 38 | Name: "url", 39 | Usage: "set the Elasticsearch URL", 40 | Category: "Elasticsearch", 41 | Value: "", 42 | Sources: cli.EnvVars("ELASTICSEARCH_URL"), 43 | Destination: &commands.cfg.ElasticsearchURL, 44 | Action: func(ctx context.Context, c *cli.Command, s string) error { 45 | return commands.cfg.InferElasticCloudURLs() 46 | }, 47 | }, 48 | &cli.StringFlag{ 49 | Name: "username", 50 | Usage: "set the Elasticsearch username", 51 | Category: "Elasticsearch", 52 | Value: "", 53 | Sources: cli.EnvVars("ELASTICSEARCH_USERNAME"), 54 | Destination: &commands.cfg.Username, 55 | }, 56 | &cli.StringFlag{ 57 | Name: "password", 58 | Usage: "set the Elasticsearch password", 59 | Category: "Elasticsearch", 60 | Sources: cli.EnvVars("ELASTICSEARCH_PASSWORD"), 61 | Destination: &commands.cfg.Password, 62 | }, 63 | &cli.StringFlag{ 64 | Name: "api-key", 65 | Usage: "set the Elasticsearch API Key", 66 | Category: "Elasticsearch", 67 | Sources: cli.EnvVars("ELASTICSEARCH_API_KEY"), 68 | Destination: &commands.cfg.APIKey, 69 | }, 70 | &cli.StringFlag{ 71 | Name: "apm-url", 72 | Usage: "set the APM Server URL. Will be derived from the Elasticsearch URL for Elastic Cloud.", 73 | Category: "APM", 74 | Value: "", 75 | Sources: cli.EnvVars("ELASTIC_APM_SERVER_URL"), 76 | Destination: &commands.cfg.APMServerURL, 77 | }, 78 | &cli.BoolFlag{ 79 | Name: "insecure", 80 | Usage: "skip TLS certificate verification of Elasticsearch and APM server", 81 | Value: false, 82 | Sources: cli.EnvVars("TLS_SKIP_VERIFY"), 83 | Destination: &commands.cfg.TLSSkipVerify, 84 | }, 85 | }, 86 | Commands: []*cli.Command{ 87 | NewPrintEnvCmd(commands), 88 | NewSendEventCmd(commands), 89 | NewUploadSourcemapCmd(commands), 90 | NewListServiceCmd(commands), 91 | NewTraceGenCmd(commands), 92 | NewESPollCmd(commands), 93 | }, 94 | } 95 | if err := cmd.Run(context.Background(), os.Args); err != nil { 96 | log.Fatal(err) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /cmd/apmtool/events.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package main 19 | 20 | import ( 21 | "context" 22 | "fmt" 23 | "io" 24 | "log" 25 | "net/http" 26 | "os" 27 | 28 | "github.com/urfave/cli/v3" 29 | ) 30 | 31 | func (cmd *Commands) sendEventsCommand(ctx context.Context, c *cli.Command) error { 32 | creds, err := cmd.getCredentials(ctx, c) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | var body io.Reader 38 | filename := c.String("file") 39 | if filename == "" { 40 | stat, err := os.Stdin.Stat() 41 | if err != nil { 42 | log.Fatalf("failed to stat stdin: %s", err.Error()) 43 | } 44 | if stat.Size() == 0 { 45 | log.Fatal("empty -file flag and stdin, please set one.") 46 | } 47 | body = io.NopCloser(os.Stdin) 48 | } else { 49 | f, err := os.Open(filename) 50 | if err != nil { 51 | return fmt.Errorf("error opening file: %w", err) 52 | } 53 | defer f.Close() 54 | body = f 55 | } 56 | 57 | urlPath := "/intake/v2/events" 58 | if c.Bool("rumv2") { 59 | urlPath = "/intake/v2/rum/events" 60 | } 61 | req, err := http.NewRequest( 62 | http.MethodPost, 63 | cmd.cfg.APMServerURL+urlPath+"?verbose", 64 | body, 65 | ) 66 | if err != nil { 67 | return fmt.Errorf("error creating HTTP request: %w", err) 68 | } 69 | req.Header.Set("Content-Type", "application/x-ndjson") 70 | 71 | switch { 72 | case creds.SecretToken != "": 73 | req.Header.Set("Authorization", "Bearer "+creds.SecretToken) 74 | case creds.APIKey != "": 75 | req.Header.Set("Authorization", "ApiKey "+creds.APIKey) 76 | } 77 | 78 | resp, err := http.DefaultClient.Do(req) 79 | if err != nil { 80 | return fmt.Errorf("error performing HTTP request: %w", err) 81 | } 82 | defer resp.Body.Close() 83 | io.Copy(os.Stderr, resp.Body) 84 | if resp.StatusCode != http.StatusAccepted { 85 | return fmt.Errorf("error sending events; server responded with %q", resp.Status) 86 | } 87 | return nil 88 | } 89 | 90 | // NewSendEventCmd returns pointer to a Command that sends events to APM Server 91 | func NewSendEventCmd(commands *Commands) *cli.Command { 92 | return &cli.Command{ 93 | Name: "send-events", 94 | Usage: "send events stored in ND-JSON format", 95 | Action: commands.sendEventsCommand, 96 | Flags: []cli.Flag{ 97 | &cli.StringFlag{ 98 | Name: "file", 99 | Aliases: []string{"f"}, 100 | Required: true, 101 | Usage: "File containing the payload to send, in ND-JSON format. Payload must be provided via this flag or stdin.", 102 | }, 103 | &cli.BoolFlag{ 104 | Name: "rumv2", 105 | Usage: "Send events to /intake/v2/rum/events", 106 | }, 107 | }, 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /pkg/approvaltest/sort.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package approvaltest 19 | 20 | import ( 21 | "bytes" 22 | "encoding/json" 23 | "strings" 24 | 25 | "github.com/tidwall/gjson" 26 | ) 27 | 28 | type eventType int 29 | 30 | const ( 31 | undefinedEventType eventType = iota 32 | errorEventType 33 | logEventType 34 | metricEventType 35 | spanEventType 36 | transactionEventType 37 | ) 38 | 39 | // eventType derives the event type from various fields. 40 | func getEventType(fields json.RawMessage) eventType { 41 | datastreamType := gjson.GetBytes(fields, `data_stream\.type.0`) 42 | datastreamDataset := gjson.GetBytes(fields, `data_stream\.dataset.0`) 43 | switch datastreamType.Str { 44 | case "logs": 45 | if datastreamDataset.Str == "apm.error" { 46 | return errorEventType 47 | } 48 | return logEventType 49 | case "metrics": 50 | return metricEventType 51 | case "traces": 52 | if gjson.GetBytes(fields, `span\.type`).Exists() { 53 | return spanEventType 54 | } 55 | if gjson.GetBytes(fields, `transaction\.type`).Exists() { 56 | return transactionEventType 57 | } 58 | } 59 | return undefinedEventType 60 | } 61 | 62 | var docSortFields = []string{ 63 | "trace.id", 64 | "transaction.id", 65 | "span.id", 66 | "error.id", 67 | "transaction.name", 68 | "span.destination.service.resource", 69 | "transaction.type", 70 | "span.type", 71 | "service.name", 72 | "service.environment", 73 | "message", 74 | "metricset.interval", // useful to sort different interval metric sets. 75 | "@timestamp", // last resort before _id; order is generally guaranteed 76 | } 77 | 78 | func compareDocumentFields(i, j json.RawMessage) int { 79 | // NOTE(axw) we should remove this event type derivation and comparison 80 | // in the future, and sort purely on fields. We're doing this to avoid 81 | // reordering all the approval files while removing `processor.event`. 82 | // If/when we change sort order, we should add a tool for re-sorting 83 | // *.approved.json files. 84 | if n := getEventType(i) - getEventType(j); n != 0 { 85 | return int(n) 86 | } 87 | for _, field := range docSortFields { 88 | path := strings.ReplaceAll(field, ".", "\\.") 89 | ri := gjson.GetBytes(i, path) 90 | rj := gjson.GetBytes(j, path) 91 | if ri.Exists() && rj.Exists() { 92 | // 'fields' always returns an array 93 | // of values, but all of the fields 94 | // we sort on are single value fields. 95 | ri = ri.Array()[0] 96 | rj = rj.Array()[0] 97 | } 98 | if ri.Less(rj, true) { 99 | return -1 100 | } 101 | if rj.Less(ri, true) { 102 | return 1 103 | } 104 | } 105 | // All sort fields are equivalent, so compare bytes. 106 | return bytes.Compare(i, j) 107 | } 108 | -------------------------------------------------------------------------------- /cmd/apmtool/tracegen.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package main 19 | 20 | import ( 21 | "context" 22 | "fmt" 23 | "math/rand" 24 | "os" 25 | "os/signal" 26 | 27 | "github.com/urfave/cli/v3" 28 | 29 | "github.com/elastic/apm-tools/pkg/tracegen" 30 | ) 31 | 32 | func (cmd *Commands) sendTrace(ctx context.Context, c *cli.Command) error { 33 | creds, err := cmd.getCredentials(ctx, c) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | cfg := tracegen.NewConfig( 39 | tracegen.WithAPMServerURL(cmd.cfg.APMServerURL), 40 | tracegen.WithAPIKey(creds.APIKey), 41 | tracegen.WithSampleRate(c.Float("sample-rate")), 42 | tracegen.WithInsecureConn(cmd.cfg.TLSSkipVerify), 43 | tracegen.WithOTLPProtocol(c.String("otlp-protocol")), 44 | tracegen.WithOTLPServiceName(newUniqueServiceName("service", "otlp")), 45 | tracegen.WithElasticAPMServiceName(newUniqueServiceName("service", "intake")), 46 | ) 47 | ctx, cancel := signal.NotifyContext(context.Background(), os.Kill, os.Interrupt) 48 | defer cancel() 49 | 50 | stats, err := tracegen.SendDistributedTrace(ctx, cfg) 51 | if err != nil { 52 | return fmt.Errorf("error sending distributed trace: %w", err) 53 | } 54 | fmt.Printf( 55 | "Sent %d span%s, %d exception%s, and %d log%s\n", 56 | stats.SpansSent, pluralize(stats.SpansSent), 57 | stats.ExceptionsSent, pluralize(stats.ExceptionsSent), 58 | stats.LogsSent, pluralize(stats.LogsSent), 59 | ) 60 | 61 | return nil 62 | } 63 | 64 | func pluralize(n int) string { 65 | if n == 1 { 66 | return "" 67 | } 68 | return "s" 69 | } 70 | 71 | func newUniqueServiceName(prefix string, suffix string) string { 72 | uniqueName := suffixString(suffix) 73 | return prefix + "-" + uniqueName 74 | } 75 | 76 | func suffixString(s string) string { 77 | const letter = "abcdefghijklmnopqrstuvwxyz" 78 | b := make([]byte, 6) 79 | for i := range b { 80 | b[i] = letter[rand.Intn(len(letter))] 81 | } 82 | return fmt.Sprintf("%s-%s", s, string(b)) 83 | } 84 | 85 | // NewTraceGenCmd returns pointer to a Command that generates distributed tracing data using go-agent and otel library 86 | func NewTraceGenCmd(commands *Commands) *cli.Command { 87 | return &cli.Command{ 88 | Name: "generate-trace", 89 | Usage: "generate distributed tracing data using go-agent and otel library", 90 | Action: commands.sendTrace, 91 | Flags: []cli.Flag{ 92 | &cli.FloatFlag{ 93 | Name: "sample-rate", 94 | Usage: "set the sample rate. allowed value: min: 0.0001, max: 1.000", 95 | Value: 1.0, 96 | }, 97 | &cli.StringFlag{ 98 | Name: "otlp-protocol", 99 | Usage: "set OTLP transport protocol to one of: grpc (default), http/protobuf", 100 | Value: "grpc", 101 | }, 102 | }, 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /pkg/metricgen/config.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package metricgen 19 | 20 | import ( 21 | "errors" 22 | "fmt" 23 | ) 24 | 25 | type ConfigOption func(*config) 26 | 27 | type config struct { 28 | // apiKey holds an Elasticsearch API key. 29 | apiKey string 30 | // apmServerURL holdes the Elasticsearch APM server URL endpoint. 31 | apmServerURL string 32 | // verifyServerCert determines if endpoint TLS certificates will be validated. 33 | verifyServerCert bool 34 | 35 | // apmServiceName holds the service name sent with Elastic APM metrics. 36 | apmServiceName string 37 | // otlpServiceName holds the service name sent with OTLP metrics. 38 | otlpServiceName string 39 | // otlpProtocol specifies the OTLP protocol to use for sending metrics. 40 | // Valid values are: grpc, http/protobuf. 41 | otlpProtocol string 42 | } 43 | 44 | const ( 45 | grpcOTLPProtocol = "grpc" 46 | httpOTLPProtocol = "http/protobuf" 47 | ) 48 | 49 | func (cfg config) Validate() error { 50 | var errs []error 51 | if cfg.apmServiceName == "" && cfg.otlpServiceName == "" { 52 | errs = append(errs, errors.New("both APM service name and OTLP service name cannot be empty")) 53 | } 54 | 55 | if cfg.apmServerURL == "" { 56 | errs = append(errs, errors.New("APM server URL cannot be empty")) 57 | } 58 | if cfg.apiKey == "" { 59 | errs = append(errs, errors.New("API Key cannot be empty")) 60 | } 61 | 62 | switch cfg.otlpProtocol { 63 | case httpOTLPProtocol, grpcOTLPProtocol: 64 | default: 65 | errs = append(errs, fmt.Errorf("unknown otlp protocol: %s", cfg.otlpProtocol)) 66 | } 67 | 68 | if len(errs) > 0 { 69 | return errors.Join(errs...) 70 | } 71 | 72 | return nil 73 | } 74 | 75 | func newConfig(opts ...ConfigOption) config { 76 | cfg := config{ 77 | otlpProtocol: "grpc", 78 | } 79 | for _, opt := range opts { 80 | opt(&cfg) 81 | } 82 | 83 | return cfg 84 | } 85 | 86 | func WithAPIKey(s string) ConfigOption { 87 | return func(c *config) { 88 | c.apiKey = s 89 | } 90 | } 91 | 92 | func WithAPMServerURL(s string) ConfigOption { 93 | return func(c *config) { 94 | c.apmServerURL = s 95 | } 96 | } 97 | 98 | func WithVerifyServerCert(b bool) ConfigOption { 99 | return func(c *config) { 100 | c.verifyServerCert = b 101 | } 102 | } 103 | 104 | // WithElasticAPMServiceName specifies the service name that 105 | // the Elastic APM agent will use. 106 | // 107 | // This config will be ignored when using SendOTLPTrace. 108 | func WithElasticAPMServiceName(s string) ConfigOption { 109 | return func(c *config) { 110 | c.apmServiceName = s 111 | } 112 | } 113 | 114 | // WithOTLPServiceName specifies the service name that the 115 | // OpenTelemetry SDK will use. 116 | // 117 | // This config will be ignored when using SendIntakeV2Trace. 118 | func WithOTLPServiceName(s string) ConfigOption { 119 | return func(c *config) { 120 | c.otlpServiceName = s 121 | } 122 | } 123 | 124 | // WithOTLPProtocol specifies OTLP transport protocol to one of: 125 | // grpc (default), http/protobuf. 126 | // 127 | // This config will be ignored when using SendIntakeV2Trace 128 | func WithOTLPProtocol(p string) ConfigOption { 129 | return func(c *config) { 130 | c.otlpProtocol = p 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /cmd/apmtool/cache.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package main 19 | 20 | import ( 21 | "errors" 22 | "fmt" 23 | "os" 24 | "path/filepath" 25 | "strings" 26 | 27 | "github.com/gofrs/flock" 28 | ) 29 | 30 | var ( 31 | cacheDir = initCacheDir() 32 | ) 33 | 34 | // readCache reads a file in the cache directory with a file lock, 35 | // return an error satisfying errors.Is(err, os.ErrNotExist) if the 36 | // cache file does not yet exist. 37 | // 38 | // The filename may not start with a '.'. 39 | func readCache(filename string) ([]byte, error) { 40 | if strings.HasPrefix(filename, ".") { 41 | return nil, fmt.Errorf("invalid filename %q, may not start with '.'", filename) 42 | } 43 | 44 | cacheFlock := newCacheFlock() 45 | if err := cacheFlock.Lock(); err != nil { 46 | return nil, fmt.Errorf("error acquiring lock on cache directory: %w", err) 47 | } 48 | defer cacheFlock.Unlock() 49 | 50 | cacheFilePath := filepath.Join(cacheDir, filename) 51 | data, err := os.ReadFile(cacheFilePath) 52 | if err != nil { 53 | return nil, fmt.Errorf("error reading cache file: %w", err) 54 | } 55 | return data, nil 56 | } 57 | 58 | // updateCache reads a file in the cache directory with a file lock, 59 | // passes it to the update function, and then writes the result back 60 | // to the file. If the file does not exist initially, a nil slice is 61 | // passed to the update function. 62 | // 63 | // The filename may not start with a '.'. 64 | func updateCache(filename string, update func([]byte) ([]byte, error)) error { 65 | if strings.HasPrefix(filename, ".") { 66 | return fmt.Errorf("invalid filename %q, may not start with '.'", filename) 67 | } 68 | 69 | cacheFlock := newCacheFlock() 70 | if err := cacheFlock.Lock(); err != nil { 71 | return fmt.Errorf("error acquiring lock on cache directory: %w", err) 72 | } 73 | defer cacheFlock.Unlock() 74 | 75 | cacheFilePath := filepath.Join(cacheDir, filename) 76 | cacheFilePathTemp := filepath.Join(cacheDir, ".temp."+filename) 77 | data, err := os.ReadFile(cacheFilePath) 78 | if err != nil { 79 | if !errors.Is(err, os.ErrNotExist) { 80 | return fmt.Errorf("error reading cache file: %w", err) 81 | } 82 | data = nil 83 | } 84 | data, err = update(data) 85 | if err != nil { 86 | return fmt.Errorf("error updating cache file %q: %w", filename, err) 87 | } 88 | 89 | // Write temporary file and rename, to prevent partial writes. 90 | if err := os.WriteFile(cacheFilePathTemp, data, 0600); err != nil { 91 | return fmt.Errorf("error writing temporary cache file %q: %w", filename, err) 92 | } 93 | if err := os.Rename(cacheFilePathTemp, cacheFilePath); err != nil { 94 | return fmt.Errorf("error renaming temporary cache file %q: %w", filename, err) 95 | } 96 | return nil 97 | } 98 | 99 | func newCacheFlock() *flock.Flock { 100 | return flock.New(filepath.Join(cacheDir, ".flock")) 101 | } 102 | 103 | type CredentialsCache struct { 104 | } 105 | 106 | func initCacheDir() string { 107 | userCacheDir, err := os.UserCacheDir() 108 | if err != nil { 109 | panic(fmt.Errorf("error getting user cache dir: %w", err)) 110 | } 111 | dir := filepath.Join(userCacheDir, "apmtool") 112 | if err := os.MkdirAll(dir, 0700); err != nil { 113 | panic(fmt.Errorf("error creating cache dir: %w", err)) 114 | } 115 | return dir 116 | } 117 | -------------------------------------------------------------------------------- /pkg/tracegen/intake.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | // Package tracegen contains functions that generate a trace including transaction, 19 | // span, error, and logs using elastic APM Go agent and opentelemtry-go 20 | package tracegen 21 | 22 | import ( 23 | "context" 24 | "crypto/tls" 25 | "errors" 26 | "fmt" 27 | "net/url" 28 | "time" 29 | 30 | "go.elastic.co/apm/v2" 31 | "go.elastic.co/apm/v2/transport" 32 | ) 33 | 34 | // SendIntakeV2Trace generate a trace including a transaction, a span and an error 35 | func SendIntakeV2Trace(ctx context.Context, cfg Config) (apm.TraceContext, EventStats, error) { 36 | if err := cfg.validate(); err != nil { 37 | return apm.TraceContext{}, EventStats{}, err 38 | } 39 | 40 | tracer, err := newTracer(cfg) 41 | if err != nil { 42 | return apm.TraceContext{}, EventStats{}, fmt.Errorf("failed to create tracer: %w", err) 43 | } 44 | defer tracer.Close() 45 | 46 | // set sample rate 47 | ts := apm.NewTraceState(apm.TraceStateEntry{ 48 | Key: "es", Value: fmt.Sprintf("s:%.4g", cfg.sampleRate), 49 | }) 50 | 51 | traceContext := apm.TraceContext{ 52 | Trace: cfg.traceID, 53 | Options: apm.TraceOptions(0).WithRecorded(true), 54 | State: ts, 55 | } 56 | 57 | tx := tracer.StartTransactionOptions("parent-tx", "apmtool", apm.TransactionOptions{ 58 | TraceContext: traceContext, 59 | }) 60 | 61 | span := tx.StartSpanOptions("parent-span", "apmtool", apm.SpanOptions{ 62 | Parent: tx.TraceContext(), 63 | }) 64 | 65 | exit := tx.StartSpanOptions("exit-span", "apmtool", apm.SpanOptions{ 66 | Parent: span.TraceContext(), 67 | ExitSpan: true, 68 | }) 69 | 70 | exit.Context.SetServiceTarget(apm.ServiceTargetSpanContext{ 71 | Type: "service_type", 72 | Name: "service_name", 73 | }) 74 | 75 | exit.Duration = 999 * time.Millisecond 76 | exit.Outcome = "failure" 77 | 78 | // error 79 | e := tracer.NewError(errors.New("timeout")) 80 | e.Culprit = "timeout" 81 | e.SetSpan(exit) 82 | e.Send() 83 | exit.End() 84 | 85 | span.Duration = time.Second 86 | span.Outcome = "success" 87 | span.End() 88 | tx.Duration = 2 * time.Second 89 | tx.Outcome = "success" 90 | tx.End() 91 | 92 | tracer.Flush(ctx.Done()) 93 | tracerStats := tracer.Stats() 94 | stats := EventStats{ 95 | ExceptionsSent: int(tracerStats.ErrorsSent), 96 | SpansSent: int(tracerStats.SpansSent + tracerStats.TransactionsSent), 97 | } 98 | 99 | return tx.TraceContext(), stats, nil 100 | } 101 | 102 | func newTracer(cfg Config) (*apm.Tracer, error) { 103 | apmServerURL, err := url.Parse(cfg.apmServerURL) 104 | if err != nil { 105 | return nil, fmt.Errorf("failed to parse endpoint: %w", err) 106 | } 107 | 108 | var apmServerTLSConfig *tls.Config 109 | if cfg.insecure { 110 | apmServerTLSConfig = &tls.Config{InsecureSkipVerify: true} 111 | } 112 | 113 | apmTransport, err := transport.NewHTTPTransport(transport.HTTPTransportOptions{ 114 | ServerURLs: []*url.URL{apmServerURL}, 115 | APIKey: cfg.apiKey, 116 | UserAgent: "apm-tool", 117 | TLSClientConfig: apmServerTLSConfig, 118 | }) 119 | if err != nil { 120 | return nil, fmt.Errorf("failed to create APM transport: %w", err) 121 | } 122 | return apm.NewTracerOptions(apm.TracerOptions{ 123 | ServiceName: cfg.apmServiceName, 124 | ServiceVersion: "0.0.1", 125 | Transport: apmTransport, 126 | }) 127 | } 128 | -------------------------------------------------------------------------------- /cmd/apmtool/sourcemap.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package main 19 | 20 | import ( 21 | "bytes" 22 | "context" 23 | "fmt" 24 | "io" 25 | "log" 26 | "mime/multipart" 27 | "net/http" 28 | "os" 29 | 30 | "github.com/urfave/cli/v3" 31 | ) 32 | 33 | func (cmd *Commands) uploadSourcemapCommand(ctx context.Context, c *cli.Command) error { 34 | var data bytes.Buffer 35 | mw := multipart.NewWriter(&data) 36 | mw.WriteField("service_name", c.String("service-name")) 37 | mw.WriteField("service_version", c.String("service-version")) 38 | mw.WriteField("bundle_filepath", c.String("bundle-filepath")) 39 | sourcemapFileWriter, err := mw.CreateFormFile("sourcemap", "sourcemap.js.map") 40 | if err != nil { 41 | return err 42 | } 43 | if filename := c.String("file"); filename == "" { 44 | stat, err := os.Stdin.Stat() 45 | if err != nil { 46 | log.Fatalf("failed to stat stdin: %s", err.Error()) 47 | } 48 | if stat.Size() == 0 { 49 | log.Fatal("empty -file flag and stdin, please set one.") 50 | } 51 | if _, err := io.Copy(sourcemapFileWriter, os.Stdin); err != nil { 52 | return err 53 | } 54 | } else { 55 | f, err := os.Open(filename) 56 | if err != nil { 57 | return fmt.Errorf("error opening file: %w", err) 58 | } 59 | if _, err := io.Copy(sourcemapFileWriter, f); err != nil { 60 | f.Close() 61 | return err 62 | } 63 | f.Close() 64 | } 65 | if err := mw.Close(); err != nil { 66 | return err 67 | } 68 | 69 | req, err := http.NewRequest( 70 | http.MethodPost, 71 | cmd.cfg.KibanaURL+"/api/apm/sourcemaps", 72 | bytes.NewReader(data.Bytes()), 73 | ) 74 | if err != nil { 75 | return fmt.Errorf("error creating HTTP request: %w", err) 76 | } 77 | req.SetBasicAuth(cmd.cfg.Username, cmd.cfg.Password) 78 | req.Header.Set("Content-Type", mw.FormDataContentType()) 79 | req.Header.Set("kbn-xsrf", "1") 80 | 81 | resp, err := http.DefaultClient.Do(req) 82 | if err != nil { 83 | return fmt.Errorf("error performing HTTP request: %w", err) 84 | } 85 | defer resp.Body.Close() 86 | io.Copy(os.Stderr, resp.Body) 87 | fmt.Fprintln(os.Stderr) 88 | if resp.StatusCode != http.StatusOK { 89 | return fmt.Errorf("error uploading sourcemap; server responded with %q", resp.Status) 90 | } 91 | return nil 92 | } 93 | 94 | // NewUploadSourcemapCmd returns pointer to a Command that uploads a source map to Kibana 95 | func NewUploadSourcemapCmd(commands *Commands) *cli.Command { 96 | return &cli.Command{ 97 | Name: "upload-sourcemap", 98 | Usage: "upload a source map to Kibana", 99 | Action: commands.uploadSourcemapCommand, 100 | Flags: []cli.Flag{ 101 | &cli.StringFlag{ 102 | Name: "file", 103 | Aliases: []string{"f"}, 104 | Required: true, 105 | Usage: "File containing the sourcemap to upload. Sourcemap must be provided via this flag or stdin.", 106 | }, 107 | &cli.StringFlag{ 108 | Name: "service-name", 109 | Required: true, 110 | Usage: "service.name value to match against events", 111 | }, 112 | &cli.StringFlag{ 113 | Name: "service-version", 114 | Required: true, 115 | Usage: "service.version value to match against events", 116 | }, 117 | &cli.StringFlag{ 118 | Name: "bundle-filepath", 119 | Required: true, 120 | Usage: "Source bundle filepath to match against stack frames locations.", 121 | }, 122 | }, 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /cmd/apmtool/credentials.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package main 19 | 20 | import ( 21 | "context" 22 | "encoding/json" 23 | "errors" 24 | "fmt" 25 | "os" 26 | "time" 27 | 28 | "github.com/urfave/cli/v3" 29 | ) 30 | 31 | type credentials struct { 32 | Expiry time.Time `json:"expiry,omitempty"` 33 | APIKey string `json:"api_key,omitempty"` 34 | SecretToken string `json:"secret_token,omitempty"` 35 | } 36 | 37 | // readCachedCredentials returns any cached credentials for the given URL. 38 | // If there are no cached credentials, readCachedCredentials returns an error 39 | // satisfying errors.Is(err, os.ErrNotExist). 40 | func readCachedCredentials(url string) (*credentials, error) { 41 | data, err := readCache("credentials.json") 42 | if err != nil { 43 | return nil, fmt.Errorf("error reading cached credentials: %w", err) 44 | } 45 | var m map[string]*credentials 46 | if err := json.Unmarshal(data, &m); err != nil { 47 | return nil, fmt.Errorf("error decoding cached credentials: %w", err) 48 | } 49 | if c, ok := m[url]; ok { 50 | return c, nil 51 | } 52 | return nil, fmt.Errorf("no credentials cached for %q: %w", url, os.ErrNotExist) 53 | } 54 | 55 | // updateCachedCredentials updates credentials for the given URL. 56 | // 57 | // Any expired credentials will be remove from the cache. 58 | func updateCachedCredentials(url string, c *credentials) error { 59 | if err := updateCache("credentials.json", func(data []byte) ([]byte, error) { 60 | m := make(map[string]*credentials) 61 | if data != nil { 62 | if err := json.Unmarshal(data, &m); err != nil { 63 | return nil, err 64 | } 65 | } 66 | m[url] = c 67 | now := time.Now() 68 | for k, v := range m { 69 | if !v.Expiry.IsZero() && v.Expiry.Before(now) { 70 | delete(m, k) 71 | } 72 | } 73 | return json.Marshal(m) 74 | }); err != nil { 75 | return fmt.Errorf("error updating cached credentials: %w", err) 76 | } 77 | return nil 78 | } 79 | 80 | func (cmd *Commands) getCredentials(ctx context.Context, c *cli.Command) (*credentials, error) { 81 | creds, err := readCachedCredentials(cmd.cfg.APMServerURL) 82 | if err == nil { 83 | return creds, nil 84 | } else if !errors.Is(err, os.ErrNotExist) { 85 | return nil, err 86 | } 87 | 88 | client, err := cmd.getClient() 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | var expiry time.Time 94 | // First check if there's an Elastic Cloud integration policy, 95 | // and extract a secret token from that. Otherwise, create an 96 | // API Key. 97 | var apiKey, secretToken string 98 | policy, err := client.GetElasticCloudAPMInput(ctx) 99 | policyErr := fmt.Errorf("error getting APM cloud input: %w", err) 100 | if err != nil { 101 | if c.Bool("verbose") { 102 | fmt.Fprintln(os.Stderr, policyErr) 103 | } 104 | } else { 105 | secretToken = policy.Get("apm-server.auth.secret_token").String() 106 | } 107 | // Create an API Key. 108 | fmt.Fprintln(os.Stderr, "Creating agent API Key...") 109 | expiryDuration := c.Duration("api-key-expiration") 110 | if expiryDuration > 0 { 111 | expiry = time.Now().Add(expiryDuration) 112 | } 113 | apiKey, err = client.CreateAgentAPIKey(ctx, expiryDuration) 114 | if err != nil { 115 | apiKeyErr := err 116 | return nil, fmt.Errorf( 117 | "failed to obtain agent credentials: %w", 118 | errors.Join(apiKeyErr, policyErr), 119 | ) 120 | } 121 | creds = &credentials{ 122 | Expiry: expiry, 123 | APIKey: apiKey, 124 | SecretToken: secretToken, 125 | } 126 | if err := updateCachedCredentials(cmd.cfg.APMServerURL, creds); err != nil { 127 | return nil, err 128 | } 129 | return creds, nil 130 | } 131 | -------------------------------------------------------------------------------- /pkg/tracegen/config.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package tracegen 19 | 20 | import ( 21 | "errors" 22 | "fmt" 23 | "math" 24 | "os" 25 | 26 | "go.elastic.co/apm/v2" 27 | ) 28 | 29 | type ConfigOption func(*Config) 30 | type Config struct { 31 | apmServerURL string 32 | apiKey string 33 | sampleRate float64 34 | traceID apm.TraceID 35 | insecure bool 36 | 37 | apmServiceName string 38 | otlpServiceName string 39 | otlpProtocol string 40 | } 41 | 42 | func NewConfig(opts ...ConfigOption) Config { 43 | cfg := Config{ 44 | sampleRate: 1.0, 45 | traceID: NewRandomTraceID(), 46 | insecure: false, 47 | otlpProtocol: "grpc", 48 | } 49 | for _, opt := range opts { 50 | opt(&cfg) 51 | } 52 | 53 | cfg.configureEnv() 54 | 55 | return cfg 56 | } 57 | 58 | // WithSampleRate specifies the sample rate for the APM GO Agent 59 | func WithSampleRate(r float64) ConfigOption { 60 | return func(c *Config) { 61 | c.sampleRate = math.Round(r*10000) / 10000 62 | } 63 | } 64 | 65 | // WithAPMServerURL set APM Server URL (env value ELASTIC_APM_SERVER_URL) 66 | func WithAPMServerURL(a string) ConfigOption { 67 | return func(c *Config) { 68 | c.apmServerURL = a 69 | } 70 | } 71 | 72 | // WithAPIKey sets auth apiKey to communicate with APM Server 73 | func WithAPIKey(k string) ConfigOption { 74 | return func(c *Config) { 75 | c.apiKey = k 76 | } 77 | } 78 | 79 | // WithTraceID specifies the user defined traceID 80 | func WithTraceID(t apm.TraceID) ConfigOption { 81 | return func(c *Config) { 82 | c.traceID = t 83 | } 84 | } 85 | 86 | // WithInsecureConn skip the server's TLS certificate verification 87 | func WithInsecureConn(b bool) ConfigOption { 88 | return func(c *Config) { 89 | c.insecure = b 90 | } 91 | } 92 | 93 | // WithElasticAPMServiceName specifies the service name that 94 | // the Elastic APM agent will use. 95 | // 96 | // This config will be ignored when using SendOTLPTrace. 97 | func WithElasticAPMServiceName(s string) ConfigOption { 98 | return func(c *Config) { 99 | c.apmServiceName = s 100 | } 101 | } 102 | 103 | // WithOTLPServiceName specifies the service name that the 104 | // OpenTelemetry SDK will use. 105 | // 106 | // This config will be ignored when using SendIntakeV2Trace. 107 | func WithOTLPServiceName(s string) ConfigOption { 108 | return func(c *Config) { 109 | c.otlpServiceName = s 110 | } 111 | } 112 | 113 | // WithOTLPProtocol specifies OTLP transport protocol to one of: 114 | // grpc (default), http/protobuf. 115 | // 116 | // This config will be ignored when using SendIntakeV2Trace 117 | func WithOTLPProtocol(p string) ConfigOption { 118 | return func(c *Config) { 119 | c.otlpProtocol = p 120 | } 121 | } 122 | 123 | func (cfg Config) validate() error { 124 | var errs []error 125 | if cfg.sampleRate < 0.0001 || cfg.sampleRate > 1.0 { 126 | errs = append(errs, 127 | fmt.Errorf("invalid sample rate %f provided. allowed value: 0.0001 <= sample-rate <= 1.0", cfg.sampleRate), 128 | ) 129 | } 130 | if cfg.apmServerURL == "" { 131 | errs = append(errs, errors.New("APM Server URL must be configured")) 132 | } 133 | 134 | if cfg.apiKey == "" { 135 | errs = append(errs, errors.New("API Key must be configured")) 136 | } 137 | return errors.Join(errs...) 138 | } 139 | 140 | // configureEnv parses or sets env configs to work with both Elastic GO Agent and OTLP library 141 | func (cfg *Config) configureEnv() error { 142 | if cfg.apiKey == "" { 143 | cfg.apiKey = os.Getenv("ELASTIC_APM_API_KEY") 144 | } else { 145 | os.Setenv("ELASTIC_APM_API_KEY", cfg.apiKey) 146 | } 147 | 148 | if cfg.apmServerURL == "" { 149 | cfg.apmServerURL = os.Getenv("ELASTIC_APM_SERVER_URL") 150 | } else { 151 | os.Setenv("ELASTIC_APM_SERVER_URL", cfg.apmServerURL) 152 | } 153 | 154 | if cfg.insecure { 155 | os.Setenv("ELASTIC_APM_VERIFY_SERVER_CERT", "false") 156 | } 157 | return nil 158 | } 159 | -------------------------------------------------------------------------------- /tools/go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= 2 | github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 | github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= 4 | github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/elastic/go-licenser v0.4.2 h1:bPbGm8bUd8rxzSswFOqvQh1dAkKGkgAmrPxbUi+Y9+A= 9 | github.com/elastic/go-licenser v0.4.2/go.mod h1:W8eH6FaZDR8fQGm+7FnVa7MxI1b/6dAqxz+zPB8nm5c= 10 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 11 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 12 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 13 | github.com/google/licenseclassifier v0.0.0-20250213175939-b5d1a3369749 h1:8THAWyz8RWzYr1KHeDWUTxx4Sl2kIzKDRDxhcr4lhww= 14 | github.com/google/licenseclassifier v0.0.0-20250213175939-b5d1a3369749/go.mod h1:jkYIPv59uiw+1MxTWlqQEKebsUDV1DCXQtBBn5lVzf4= 15 | github.com/google/licenseclassifier/v2 v2.0.0-alpha.1/go.mod h1:YAgBGGTeNDMU+WfIgaFvjZe4rudym4f6nIn8ZH5X+VM= 16 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 17 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 18 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 19 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 20 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 21 | github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 22 | github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= 23 | github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 24 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 25 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 26 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 27 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 28 | go.elastic.co/go-licence-detector v0.10.0 h1:B/SQPnz1gFJMcqogZPhgmq9RSOrHPnlQ0csxjUZnsW0= 29 | go.elastic.co/go-licence-detector v0.10.0/go.mod h1:NW7froix26ua2Mdkgc4F01QNytOZtcks7PHYtI9x5iQ= 30 | golang.org/x/exp/typeparams v0.0.0-20241009180824-f66d83c29e7c h1:F/15/6p7LyGUSoP0GE5CB/U9+TNEER1foNOP5sWLLnI= 31 | golang.org/x/exp/typeparams v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= 32 | golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= 33 | golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= 34 | golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 35 | golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 36 | golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 37 | golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 38 | golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA= 39 | golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= 40 | golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= 41 | golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= 42 | golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= 43 | golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= 44 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 45 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 46 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 47 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 48 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 49 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 50 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 51 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 52 | honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= 53 | honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= 54 | -------------------------------------------------------------------------------- /pkg/apmclient/config.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package apmclient 19 | 20 | import ( 21 | "fmt" 22 | "net/url" 23 | "os" 24 | "strings" 25 | ) 26 | 27 | type Config struct { 28 | // ElasticsearchURL holds the Elasticsearch URL. 29 | ElasticsearchURL string 30 | 31 | // Username holds the Elasticsearch username for basic auth. 32 | Username string 33 | 34 | // Password holds the Elasticsearch password for basic auth. 35 | Password string 36 | 37 | // APIKey holds an Elasticsearch API Key. 38 | // 39 | // This will be set from $ELASTICSEARCH_API_KEY if specified. 40 | APIKey string 41 | 42 | // APMServerURL holds the APM Server URL. 43 | // 44 | // If this is unspecified, it will be derived from 45 | // ElasticsearchURL if that is an Elastic Cloud URL. 46 | APMServerURL string 47 | 48 | // KibanaURL holds the Kibana URL. 49 | // 50 | // If this is unspecified, it will be derived from 51 | // ElasticsearchURL if that is an Elastic Cloud URL. 52 | KibanaURL string 53 | 54 | // TLSSkipVerify determines if TLS certificate 55 | // verification is skipped or not. Default to false. 56 | // 57 | // If not specified the value will be take from 58 | // TLS_SKIP_VERIFY env var. 59 | // Any value different from "" is considered true. 60 | TLSSkipVerify bool 61 | } 62 | 63 | // NewConfig returns a Config intialised from environment variables. 64 | func NewConfig() (Config, error) { 65 | cfg := Config{} 66 | err := cfg.Finalize() 67 | return cfg, err 68 | } 69 | 70 | // Finalize finalizes cfg by setting unset fields from environment 71 | // variables: 72 | // 73 | // - ElasticsearchURL is set from $ELASTICSEARCH_URL 74 | // - Username is set from $ELASTICSEARCH_USERNAME 75 | // - Password is set from $ELASTICSEARCH_PASSWORD 76 | // - API Key is set from $ELASTICSEARCH_API_KEY 77 | // - APMServerURL is set from $ELASTIC_APM_SERVER_URL 78 | // - KibanaURL is set from $KIBANA_URL 79 | // 80 | // If $ELASTIC_APM_SERVER_URL is unspecified, and ElasticsearchURL 81 | // holds an Elastic Cloud-based URL, then the APM Server URL is 82 | // derived from that. Likewise, the Kibana URL will be set in this 83 | // way if $KIBANA_URL is unspecified. 84 | func (cfg *Config) Finalize() error { 85 | if cfg.ElasticsearchURL == "" { 86 | cfg.ElasticsearchURL = os.Getenv("ELASTICSEARCH_URL") 87 | } 88 | if cfg.Username == "" { 89 | cfg.Username = os.Getenv("ELASTICSEARCH_USERNAME") 90 | } 91 | if cfg.Password == "" { 92 | cfg.Password = os.Getenv("ELASTICSEARCH_PASSWORD") 93 | } 94 | if cfg.APIKey == "" { 95 | cfg.APIKey = os.Getenv("ELASTICSEARCH_API_KEY") 96 | } 97 | if cfg.APMServerURL == "" { 98 | cfg.APMServerURL = os.Getenv("ELASTIC_APM_SERVER_URL") 99 | } 100 | if cfg.KibanaURL == "" { 101 | cfg.KibanaURL = os.Getenv("KIBANA_URL") 102 | } 103 | if env := os.Getenv("TLS_SKIP_VERIFY"); !cfg.TLSSkipVerify && env != "" { 104 | cfg.TLSSkipVerify = true 105 | } 106 | return cfg.InferElasticCloudURLs() 107 | } 108 | 109 | // InferElasticCloudURLs attempts to infer a value for APMServerURL 110 | // and KibanaURL (if they are empty), by checking if ElasticsearchURL 111 | // matches an Elastic Cloud URL pattern, and deriving the other URLs 112 | // from that. 113 | func (cfg *Config) InferElasticCloudURLs() error { 114 | if cfg.ElasticsearchURL == "" { 115 | return nil 116 | } 117 | if cfg.APMServerURL != "" && cfg.KibanaURL != "" { 118 | return nil 119 | } 120 | 121 | // If ElasticsearchURL matches https://.es.<...> 122 | // then derive the APM Server URL from that by substituting 123 | // "apm" for "es", and Kibana URL by substituing "kb". 124 | url, err := url.Parse(cfg.ElasticsearchURL) 125 | if err != nil { 126 | return fmt.Errorf("error parsing ElasticsearchURL: %w", err) 127 | } 128 | if alias, remainder, ok := strings.Cut(url.Host, "."); ok { 129 | if component, remainder, ok := strings.Cut(remainder, "."); ok && component == "es" { 130 | if cfg.APMServerURL == "" { 131 | url.Host = fmt.Sprintf("%s.apm.%s", alias, remainder) 132 | cfg.APMServerURL = url.String() 133 | } 134 | if cfg.KibanaURL == "" { 135 | url.Host = fmt.Sprintf("%s.kb.%s", alias, remainder) 136 | cfg.KibanaURL = url.String() 137 | } 138 | } 139 | } 140 | return nil 141 | } 142 | -------------------------------------------------------------------------------- /cmd/apmtool/espoll.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package main 19 | 20 | import ( 21 | "context" 22 | "crypto/tls" 23 | "encoding/json" 24 | "errors" 25 | "fmt" 26 | "io" 27 | "log" 28 | "net/http" 29 | "os" 30 | "os/signal" 31 | "strings" 32 | "time" 33 | 34 | "github.com/urfave/cli/v3" 35 | 36 | "github.com/elastic/apm-tools/pkg/espoll" 37 | "github.com/elastic/go-elasticsearch/v8" 38 | ) 39 | 40 | var maxElasticsearchBackoff = 10 * time.Second 41 | 42 | type config struct { 43 | query string 44 | esURL string 45 | esUsername string 46 | esPassword string 47 | 48 | tlsSkipVerify bool 49 | 50 | target string 51 | timeout time.Duration 52 | hits uint64 53 | } 54 | 55 | func (cmd *Commands) pollDocs(ctx context.Context, c *cli.Command) error { 56 | cfg := config{ 57 | query: c.String("query"), 58 | esURL: cmd.cfg.ElasticsearchURL, 59 | esUsername: cmd.cfg.Username, 60 | esPassword: cmd.cfg.Password, 61 | 62 | tlsSkipVerify: cmd.cfg.TLSSkipVerify, 63 | 64 | target: c.String("target"), 65 | timeout: c.Duration("timeout"), 66 | hits: c.Uint64("min-hits"), 67 | } 68 | query := c.String("query") 69 | if query == "" { 70 | stat, err := os.Stdin.Stat() 71 | if err != nil { 72 | log.Fatalf("failed to stat stdin: %s", err.Error()) 73 | } 74 | if stat.Size() == 0 { 75 | log.Fatal("empty -query flag and stdin, please set one.") 76 | } 77 | 78 | b, err := io.ReadAll(os.Stdin) 79 | if err != nil { 80 | log.Fatalf("failed to read stdin: %s", err.Error()) 81 | } 82 | query = string(strings.Trim(string(b), "\n")) 83 | } 84 | 85 | log.Println("query:", query) 86 | 87 | ctxMain, cancel := signal.NotifyContext(ctx, os.Interrupt, os.Kill) 88 | defer cancel() 89 | 90 | if err := Main(ctxMain, cfg); err != nil { 91 | log.Fatalf("ERROR: %s", err.Error()) 92 | } 93 | 94 | return nil 95 | } 96 | 97 | // NewESPollCmd returns pointer to Command that queries documents from Elasticsearch 98 | func NewESPollCmd(commands *Commands) *cli.Command { 99 | return &cli.Command{ 100 | Name: "espoll", 101 | Usage: "poll documents from Elasticsearch", 102 | Action: commands.pollDocs, 103 | Flags: []cli.Flag{ 104 | &cli.StringFlag{ 105 | Name: "query", 106 | Usage: "The Elasticsearch query in Query DSL. Must be set via this flag or stdin.", 107 | }, 108 | &cli.StringFlag{ 109 | Name: "target", 110 | Value: "traces-*,logs-*,metrics-*", 111 | Usage: "Comma-separated list of data streams, indices, and aliases to search (Supports wildcards (*)).", 112 | }, 113 | &cli.DurationFlag{ 114 | Name: "timeout", 115 | Value: 30 * time.Second, 116 | Usage: "Elasticsearch request timeout", 117 | }, 118 | &cli.UintFlag{ 119 | Name: "min-hits", 120 | Value: 1, 121 | Usage: "When specified and > 10, this should cause the size parameter to be set.", 122 | }, 123 | }, 124 | } 125 | } 126 | 127 | func Main(ctx context.Context, cfg config) error { 128 | if cfg.query == "" { 129 | return errors.New("query cannot be empty") 130 | } 131 | 132 | transport := http.DefaultTransport.(*http.Transport).Clone() 133 | transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: cfg.tlsSkipVerify} 134 | 135 | client, err := elasticsearch.NewClient(elasticsearch.Config{ 136 | Username: cfg.esUsername, 137 | Password: cfg.esPassword, 138 | Addresses: strings.Split(cfg.esURL, ","), 139 | Transport: transport, 140 | MaxRetries: 5, 141 | RetryBackoff: func(attempt int) time.Duration { 142 | backoff := (500 * time.Millisecond) * (1 << (attempt - 1)) 143 | if backoff > maxElasticsearchBackoff { 144 | backoff = maxElasticsearchBackoff 145 | } 146 | return backoff 147 | }, 148 | }) 149 | if err != nil { 150 | return err 151 | } 152 | esClient := espoll.WrapClient(client) 153 | result, err := esClient.SearchIndexMinDocs(ctx, 154 | int(cfg.hits), cfg.target, stringMarshaler(cfg.query), 155 | espoll.WithTimeout(cfg.timeout), 156 | ) 157 | if err != nil { 158 | return fmt.Errorf("search request returned error: %w", err) 159 | } 160 | 161 | if err := json.NewEncoder(os.Stdout).Encode(result); err != nil { 162 | return fmt.Errorf("failed to encode search result: %w", err) 163 | } 164 | return nil 165 | } 166 | 167 | type stringMarshaler string 168 | 169 | func (s stringMarshaler) MarshalJSON() ([]byte, error) { return []byte(s), nil } 170 | -------------------------------------------------------------------------------- /pkg/espoll/client.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | // Package espoll wraps an elasticsearch.Client to provide utilitarian methods 19 | // to assert for conditions until a certain threshold is met. 20 | package espoll 21 | 22 | import ( 23 | "bytes" 24 | "context" 25 | "encoding/json" 26 | "io" 27 | "time" 28 | 29 | "github.com/elastic/go-elasticsearch/v8" 30 | "github.com/elastic/go-elasticsearch/v8/esapi" 31 | ) 32 | 33 | // Client wraps an Elasticsearch client 34 | type Client struct { 35 | *elasticsearch.Client 36 | } 37 | 38 | // WrapClient wraps an Elasticsearch client and returns an espoll.Client 39 | func WrapClient(c *elasticsearch.Client) *Client { return &Client{Client: c} } 40 | 41 | type Request interface { 42 | Do(ctx context.Context, transport esapi.Transport) (*esapi.Response, error) 43 | } 44 | 45 | // Do performs the specified request. 46 | func (es *Client) Do( 47 | ctx context.Context, 48 | req Request, 49 | out any, 50 | opts ...RequestOption, 51 | ) (*esapi.Response, error) { 52 | requestOptions := requestOptions{ 53 | // Set the timeout to something high to account for Elasticsearch 54 | // cluster and index/shard initialisation. Under normal conditions 55 | // this timeout should never be reached. 56 | timeout: time.Minute, 57 | interval: 100 * time.Millisecond, 58 | } 59 | for _, opt := range opts { 60 | opt(&requestOptions) 61 | } 62 | var timeoutC, tickerC <-chan time.Time 63 | var transport esapi.Transport = es 64 | if requestOptions.cond != nil { 65 | // A return condition has been specified, which means we 66 | // might retry the request. Wrap the transport with a 67 | // bodyRepeater to ensure the body is copied as needed. 68 | transport = &bodyRepeater{transport, nil} 69 | if requestOptions.timeout > 0 { 70 | timeout := time.NewTimer(requestOptions.timeout) 71 | defer timeout.Stop() 72 | timeoutC = timeout.C 73 | } 74 | } 75 | 76 | var resp *esapi.Response 77 | for { 78 | if tickerC != nil { 79 | select { 80 | case <-timeoutC: 81 | return nil, context.DeadlineExceeded 82 | case <-tickerC: 83 | } 84 | } 85 | var err error 86 | resp, err = req.Do(ctx, transport) 87 | if err != nil { 88 | return nil, err 89 | } 90 | defer resp.Body.Close() 91 | if resp.IsError() { 92 | return nil, &Error{StatusCode: resp.StatusCode, Message: resp.String()} 93 | } 94 | body, err := io.ReadAll(resp.Body) 95 | if err != nil { 96 | return nil, err 97 | } 98 | if out != nil { 99 | if err := json.Unmarshal(body, out); err != nil { 100 | return nil, err 101 | } 102 | } 103 | resp.Body = io.NopCloser(bytes.NewReader(body)) 104 | if requestOptions.cond == nil || requestOptions.cond(resp) { 105 | break 106 | } 107 | if tickerC == nil { 108 | // First time around, start a ticker for retrying. 109 | ticker := time.NewTicker(requestOptions.interval) 110 | defer ticker.Stop() 111 | tickerC = ticker.C 112 | } 113 | } 114 | return resp, nil 115 | } 116 | 117 | // RequestOption modifies certain parameters for an esapi.Request. 118 | type RequestOption func(*requestOptions) 119 | 120 | type Error struct { 121 | StatusCode int 122 | Message string 123 | } 124 | 125 | func (e *Error) Error() string { 126 | return e.Message 127 | } 128 | 129 | type requestOptions struct { 130 | timeout time.Duration 131 | interval time.Duration 132 | cond ConditionFunc 133 | } 134 | 135 | // WithTimeout sets the timeout in an Elasticsearch request. 136 | func WithTimeout(d time.Duration) RequestOption { 137 | return func(opts *requestOptions) { 138 | opts.timeout = d 139 | } 140 | } 141 | 142 | // WithInterval sets the poll interval in an Elasticsearch request. 143 | func WithInterval(d time.Duration) RequestOption { 144 | return func(opts *requestOptions) { 145 | opts.interval = d 146 | } 147 | } 148 | 149 | // ConditionFunc evaluates the esapi.Response. 150 | type ConditionFunc func(*esapi.Response) bool 151 | 152 | // AllCondition returns a ConditionFunc that returns true as 153 | // long as none of the supplied conditions returns false. 154 | func AllCondition(conds ...ConditionFunc) ConditionFunc { 155 | return func(resp *esapi.Response) bool { 156 | for _, cond := range conds { 157 | if !cond(resp) { 158 | return false 159 | } 160 | } 161 | return true 162 | } 163 | } 164 | 165 | // WithCondition runs the specified condition func. 166 | func WithCondition(cond ConditionFunc) RequestOption { 167 | return func(opts *requestOptions) { 168 | opts.cond = cond 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /pkg/metricgen/otlp.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package metricgen 19 | 20 | import ( 21 | "context" 22 | "crypto/tls" 23 | "fmt" 24 | "net" 25 | "net/url" 26 | 27 | "go.opentelemetry.io/otel/attribute" 28 | "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" 29 | "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" 30 | "go.opentelemetry.io/otel/metric" 31 | sdkmetric "go.opentelemetry.io/otel/sdk/metric" 32 | "go.opentelemetry.io/otel/sdk/resource" 33 | "google.golang.org/grpc" 34 | "google.golang.org/grpc/credentials" 35 | grpcinsecure "google.golang.org/grpc/credentials/insecure" 36 | ) 37 | 38 | // SendOTLP sends specific metrics to the configured Elastic APM OTLP intake. 39 | // 40 | // Metrics are sent via the specified protocol. 41 | // 42 | // Metrics sent are: 43 | // - otlp(float64, value=1.0) 44 | func SendOTLP(ctx context.Context, opts ...ConfigOption) (EventStats, error) { 45 | cfg := newConfig(opts...) 46 | if err := cfg.Validate(); err != nil { 47 | return EventStats{}, fmt.Errorf("cannot validate OTLP Metrics configuration: %w", err) 48 | } 49 | 50 | var exporter sdkmetric.Exporter 51 | switch cfg.otlpProtocol { 52 | case grpcOTLPProtocol: 53 | e, cleanup, err := newOTLPMetricGRPCExporter(ctx, cfg) 54 | if err != nil { 55 | return EventStats{}, err 56 | } 57 | defer cleanup() 58 | 59 | exporter = e 60 | case httpOTLPProtocol: 61 | e, err := newOTLPMetricHTTPExporter(ctx, cfg) 62 | if err != nil { 63 | return EventStats{}, err 64 | } 65 | 66 | exporter = e 67 | } 68 | defer exporter.Shutdown(ctx) 69 | 70 | resource := resource.NewSchemaless( 71 | attribute.String("service.name", cfg.otlpServiceName), 72 | ) 73 | mp := sdkmetric.NewMeterProvider( 74 | sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter)), 75 | sdkmetric.WithResource(resource), 76 | ) 77 | 78 | stats := EventStats{} 79 | if err := generateMetrics(mp.Meter("metricgen"), &stats); err != nil { 80 | return stats, fmt.Errorf("cannot generate metrics: %w", err) 81 | } 82 | 83 | if err := mp.Shutdown(ctx); err != nil { 84 | return EventStats{}, fmt.Errorf("cannot shut down meter provider: %w", err) 85 | } 86 | 87 | return stats, nil 88 | } 89 | 90 | func generateMetrics(m metric.Meter, stats *EventStats) error { 91 | counter, _ := m.Float64Counter("otlp") 92 | counter.Add(context.Background(), 1) 93 | stats.Add(1) 94 | 95 | return nil 96 | } 97 | 98 | func newOTLPMetricHTTPExporter(ctx context.Context, cfg config) (*otlpmetrichttp.Exporter, error) { 99 | endpoint, err := otlpEndpoint(cfg.apmServerURL) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | tlsConfig := &tls.Config{InsecureSkipVerify: !cfg.verifyServerCert} 105 | opts := []otlpmetrichttp.Option{ 106 | otlpmetrichttp.WithEndpoint(endpoint.Host), 107 | otlpmetrichttp.WithTLSClientConfig(tlsConfig), 108 | } 109 | if endpoint.Scheme == "http" { 110 | opts = append(opts, otlpmetrichttp.WithInsecure()) 111 | } 112 | 113 | headers := map[string]string{"Authorization": "ApiKey " + cfg.apiKey} 114 | opts = append(opts, otlpmetrichttp.WithHeaders(headers)) 115 | 116 | return otlpmetrichttp.New(ctx, opts...) 117 | } 118 | 119 | func otlpEndpoint(s string) (*url.URL, error) { 120 | u, err := url.Parse(s) 121 | if err != nil { 122 | return &url.URL{}, fmt.Errorf("failed to parse endpoint: %w", err) 123 | } 124 | 125 | switch u.Scheme { 126 | case "http": 127 | if u.Port() == "" { 128 | u.Host = net.JoinHostPort(u.Host, "80") 129 | } 130 | case "https": 131 | if u.Port() == "" { 132 | u.Host = net.JoinHostPort(u.Host, "443") 133 | } 134 | default: 135 | return &url.URL{}, fmt.Errorf("endpoint must be prefixed with http:// or https://") 136 | } 137 | 138 | return u, nil 139 | } 140 | 141 | func newOTLPMetricGRPCExporter(ctx context.Context, cfg config) (*otlpmetricgrpc.Exporter, func(), error) { 142 | endpoint, err := otlpEndpoint(cfg.apmServerURL) 143 | if err != nil { 144 | return nil, func() {}, err 145 | } 146 | 147 | var transportCredentials credentials.TransportCredentials 148 | switch endpoint.Scheme { 149 | case "http": 150 | // If http:// is specified, then use insecure (plaintext). 151 | transportCredentials = grpcinsecure.NewCredentials() 152 | case "https": 153 | transportCredentials = credentials.NewTLS(&tls.Config{InsecureSkipVerify: !cfg.verifyServerCert}) 154 | } 155 | 156 | grpcConn, err := grpc.NewClient( 157 | endpoint.Host, 158 | grpc.WithTransportCredentials(transportCredentials), 159 | grpc.WithDefaultCallOptions(grpc.UseCompressor("gzip")), 160 | ) 161 | cleanup := func() { grpcConn.Close() } 162 | if err != nil { 163 | return nil, cleanup, fmt.Errorf("cannot create grpc dial context: %w", err) 164 | } 165 | 166 | opts := []otlpmetricgrpc.Option{otlpmetricgrpc.WithGRPCConn(grpcConn)} 167 | headers := map[string]string{"Authorization": "ApiKey " + cfg.apiKey} 168 | opts = append(opts, otlpmetricgrpc.WithHeaders(headers)) 169 | 170 | e, err := otlpmetricgrpc.New(ctx, opts...) 171 | return e, cleanup, err 172 | } 173 | -------------------------------------------------------------------------------- /pkg/espoll/search.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package espoll 19 | 20 | import ( 21 | "context" 22 | "encoding/json" 23 | "fmt" 24 | "strings" 25 | 26 | "github.com/elastic/go-elasticsearch/v8/esapi" 27 | "github.com/elastic/go-elasticsearch/v8/esutil" 28 | ) 29 | 30 | // SearchIndexMinDocs searches index with query, returning the results. 31 | // 32 | // If the search returns fewer than min results within 10 seconds 33 | // (by default), SearchIndexMinDocs will return an error. 34 | func (es *Client) SearchIndexMinDocs( 35 | ctx context.Context, 36 | min int, index string, 37 | query json.Marshaler, 38 | opts ...RequestOption, 39 | ) (SearchResult, error) { 40 | var result SearchResult 41 | req := es.NewSearchRequest(index) 42 | req.ExpandWildcards = "open,hidden" 43 | if min > 10 { 44 | // Size defaults to 10. If the caller expects more than 10, 45 | // return it in the search so we don't have to search again. 46 | req = req.WithSize(min) 47 | } 48 | if query != nil { 49 | req = req.WithQuery(query) 50 | } 51 | opts = append(opts, WithCondition(AllCondition( 52 | result.Hits.MinHitsCondition(min), 53 | result.Hits.TotalHitsCondition(req), 54 | ))) 55 | 56 | // Refresh the indices before issuing the search request. 57 | refreshReq := esapi.IndicesRefreshRequest{ 58 | Index: strings.Split(",", index), 59 | ExpandWildcards: "all", 60 | } 61 | rsp, err := refreshReq.Do(ctx, es.Transport) 62 | if err != nil { 63 | return result, fmt.Errorf("failed refreshing indices: %s: %w", index, err) 64 | } 65 | 66 | rsp.Body.Close() 67 | 68 | if _, err := req.Do(ctx, &result, opts...); err != nil { 69 | return result, fmt.Errorf("failed issuing request: %w", err) 70 | } 71 | return result, nil 72 | } 73 | 74 | // NewSearchRequest returns a search request using the wrapped Elasticsearch 75 | // client. 76 | func (es *Client) NewSearchRequest(index string) *SearchRequest { 77 | req := &SearchRequest{es: es} 78 | req.Index = strings.Split(index, ",") 79 | req.Body = strings.NewReader(`{"fields": ["*"]}`) 80 | return req 81 | } 82 | 83 | // SearchRequest wraps an esapi.SearchRequest with a Client. 84 | type SearchRequest struct { 85 | esapi.SearchRequest 86 | es *Client 87 | } 88 | 89 | func (r *SearchRequest) WithQuery(q any) *SearchRequest { 90 | var body struct { 91 | Query any `json:"query"` 92 | Fields []string `json:"fields"` 93 | } 94 | body.Query = q 95 | body.Fields = []string{"*"} 96 | r.Body = esutil.NewJSONReader(&body) 97 | return r 98 | } 99 | 100 | func (r *SearchRequest) WithSort(fieldDirection ...string) *SearchRequest { 101 | r.Sort = fieldDirection 102 | return r 103 | } 104 | 105 | func (r *SearchRequest) WithSize(size int) *SearchRequest { 106 | r.Size = &size 107 | return r 108 | } 109 | 110 | func (r *SearchRequest) Do(ctx context.Context, out *SearchResult, opts ...RequestOption) (*esapi.Response, error) { 111 | return r.es.Do(ctx, &r.SearchRequest, out, opts...) 112 | } 113 | 114 | type SearchResult struct { 115 | Hits SearchHits `json:"hits"` 116 | Aggregations map[string]json.RawMessage `json:"aggregations"` 117 | } 118 | 119 | type SearchHits struct { 120 | Total SearchHitsTotal `json:"total"` 121 | Hits []SearchHit `json:"hits"` 122 | } 123 | 124 | type SearchHitsTotal struct { 125 | Value int `json:"value"` 126 | Relation string `json:"relation"` // "eq" or "gte" 127 | } 128 | 129 | // NonEmptyCondition returns a ConditionFunc which will return true if h.Hits is non-empty. 130 | func (h *SearchHits) NonEmptyCondition() ConditionFunc { 131 | return h.MinHitsCondition(1) 132 | } 133 | 134 | // MinHitsCondition returns a ConditionFunc which will return true if the number of h.Hits 135 | // is at least min. 136 | func (h *SearchHits) MinHitsCondition(min int) ConditionFunc { 137 | return func(*esapi.Response) bool { return len(h.Hits) >= min } 138 | } 139 | 140 | // TotalHitsCondition returns a ConditionFunc which will return true if the number of h.Hits 141 | // is at least h.Total.Value. If the condition returns false, it will update req.Size to 142 | // accommodate the number of hits in the following search. 143 | func (h *SearchHits) TotalHitsCondition(req *SearchRequest) ConditionFunc { 144 | return func(*esapi.Response) bool { 145 | if len(h.Hits) >= h.Total.Value { 146 | return true 147 | } 148 | size := h.Total.Value 149 | req.Size = &size 150 | return false 151 | } 152 | } 153 | 154 | type SearchHit struct { 155 | Index string 156 | ID string 157 | Score float64 158 | Fields map[string][]any 159 | Source map[string]any 160 | RawSource json.RawMessage 161 | RawFields json.RawMessage 162 | } 163 | 164 | func (h *SearchHit) UnmarshalJSON(data []byte) error { 165 | var searchHit struct { 166 | Index string `json:"_index"` 167 | ID string `json:"_id"` 168 | Score float64 `json:"_score"` 169 | Source json.RawMessage `json:"_source"` 170 | Fields json.RawMessage `json:"fields"` 171 | } 172 | if err := json.Unmarshal(data, &searchHit); err != nil { 173 | return err 174 | } 175 | h.Index = searchHit.Index 176 | h.ID = searchHit.ID 177 | h.Score = searchHit.Score 178 | h.RawSource = searchHit.Source 179 | h.RawFields = searchHit.Fields 180 | h.Source = make(map[string]any) 181 | h.Fields = make(map[string][]interface{}) 182 | if err := json.Unmarshal(h.RawSource, &h.Source); err != nil { 183 | return fmt.Errorf("error unmarshaling _source: %w", err) 184 | } 185 | if err := json.Unmarshal(h.RawFields, &h.Fields); err != nil { 186 | return fmt.Errorf("error unmarshaling fields: %w", err) 187 | } 188 | return nil 189 | } 190 | 191 | func (h *SearchHit) UnmarshalSource(out any) error { 192 | return json.Unmarshal(h.RawSource, out) 193 | } 194 | -------------------------------------------------------------------------------- /pkg/apmclient/client.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package apmclient 19 | 20 | import ( 21 | "context" 22 | "crypto/tls" 23 | "encoding/json" 24 | "fmt" 25 | "net/http" 26 | "time" 27 | 28 | "github.com/tidwall/gjson" 29 | 30 | "github.com/elastic/go-elasticsearch/v8" 31 | "github.com/elastic/go-elasticsearch/v8/typedapi/core/search" 32 | "github.com/elastic/go-elasticsearch/v8/typedapi/security/createapikey" 33 | "github.com/elastic/go-elasticsearch/v8/typedapi/types" 34 | "github.com/elastic/go-elasticsearch/v8/typedapi/types/enums/sortorder" 35 | ) 36 | 37 | type Client struct { 38 | es *elasticsearch.TypedClient 39 | } 40 | 41 | // New returns a new Client for querying APM data. 42 | func New(cfg Config) (*Client, error) { 43 | transport := http.DefaultTransport.(*http.Transport).Clone() 44 | transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: cfg.TLSSkipVerify} 45 | 46 | es, err := elasticsearch.NewTypedClient(elasticsearch.Config{ 47 | Addresses: []string{cfg.ElasticsearchURL}, 48 | Username: cfg.Username, 49 | APIKey: cfg.APIKey, 50 | Password: cfg.Password, 51 | Transport: transport, 52 | }) 53 | if err != nil { 54 | return nil, fmt.Errorf("error creating Elasticsearch client: %w", err) 55 | } 56 | return &Client{ 57 | es: es, 58 | }, nil 59 | } 60 | 61 | // GetElasticCloudAPMInput returns the APM configuration as defined 62 | // in the "elastic-cloud-apm" integration policy, 63 | func (c *Client) GetElasticCloudAPMInput(ctx context.Context) (gjson.Result, error) { 64 | size := 1 65 | resp, err := c.es.Search().Index(".fleet-policies").Request(&search.Request{ 66 | Size: &size, 67 | Sort: []types.SortCombinations{types.SortOptions{ 68 | SortOptions: map[string]types.FieldSort{ 69 | "revision_idx": { 70 | Order: &sortorder.Desc, 71 | }, 72 | }, 73 | }}, 74 | Query: &types.Query{ 75 | Term: map[string]types.TermQuery{ 76 | "policy_id": { 77 | Value: "policy-elastic-agent-on-cloud", 78 | }, 79 | }, 80 | }, 81 | }).Do(ctx) 82 | if err != nil { 83 | return gjson.Result{}, fmt.Errorf("error searching .fleet-policies: %w", err) 84 | } 85 | if n := len(resp.Hits.Hits); n != 1 { 86 | return gjson.Result{}, fmt.Errorf("expected 1 policy, got %d", n) 87 | } 88 | result := gjson.GetBytes(resp.Hits.Hits[0].Source_, `data.inputs.#(id=="elastic-cloud-apm")`) 89 | if !result.Exists() { 90 | return gjson.Result{}, fmt.Errorf("input %q missing", "elastic-cloud-apm") 91 | } 92 | return result, nil 93 | } 94 | 95 | // CreateAgentAPIKey creates an agent API Key, and returns it in the 96 | // base64-encoded form that agents should provide. 97 | // 98 | // If expiration is less than or equal to zero, then the API Key never expires. 99 | func (c *Client) CreateAgentAPIKey(ctx context.Context, expiration time.Duration) (string, error) { 100 | name := "apm-agent" 101 | var maybeExpiration types.Duration 102 | if expiration > 0 { 103 | maybeExpiration = formatDurationElasticsearch(expiration) 104 | } 105 | resp, err := c.es.Security.CreateApiKey().Request(&createapikey.Request{ 106 | Name: &name, 107 | Expiration: maybeExpiration, 108 | RoleDescriptors: map[string]types.RoleDescriptor{ 109 | "apm": { 110 | Applications: []types.ApplicationPrivileges{{ 111 | Application: "apm", 112 | Resources: []string{"*"}, 113 | Privileges: []string{"event:write", "config_agent:read"}, 114 | }}, 115 | }, 116 | }, 117 | Metadata: map[string]json.RawMessage{ 118 | "application": []byte(`"apm"`), 119 | "creator": []byte(`"apmclient"`), 120 | }, 121 | }).Do(ctx) 122 | if err != nil { 123 | return "", fmt.Errorf("error creating agent API Key: %w", err) 124 | } 125 | return resp.Encoded, nil 126 | } 127 | 128 | // ServiceSummary returns ServiceSummary objects by aggregating `service_summary` metric sets. 129 | func (c *Client) ServiceSummary(ctx context.Context, options ...Option) ([]ServiceSummary, error) { 130 | // TODO options 131 | req := &search.Request{ 132 | Aggregations: map[string]types.Aggregations{ 133 | "services": { 134 | MultiTerms: &types.MultiTermsAggregation{ 135 | Terms: []types.MultiTermLookup{{ 136 | Field: "service.name", 137 | }, { 138 | Field: "service.environment", 139 | Missing: "", 140 | }, { 141 | Field: "service.language.name", 142 | }, { 143 | Field: "agent.name", 144 | }}, 145 | }, 146 | }, 147 | }, 148 | } 149 | // TODO select appropriate resolution according to the time filter. 150 | resp, err := c.es.Search(). 151 | Index("metrics-apm.service_summary.1m-*"). 152 | Size(0).Request(req).Do(ctx) 153 | if err != nil { 154 | return nil, fmt.Errorf("error search service_summmary metrics") 155 | } 156 | 157 | servicesAggregation := resp.Aggregations["services"].(*types.MultiTermsAggregate) 158 | buckets := servicesAggregation.Buckets.([]types.MultiTermsBucket) 159 | out := make([]ServiceSummary, len(buckets)) 160 | for i, bucket := range buckets { 161 | out[i] = ServiceSummary{ 162 | Name: bucket.Key[0].(string), 163 | Environment: bucket.Key[1].(string), 164 | Language: bucket.Key[2].(string), 165 | Agent: bucket.Key[3].(string), 166 | } 167 | } 168 | return out, nil 169 | } 170 | 171 | var elasticsearchTimeUnits = []struct { 172 | Duration time.Duration 173 | Unit string 174 | }{ 175 | {time.Hour, "h"}, 176 | {time.Minute, "m"}, 177 | {time.Second, "s"}, 178 | {time.Millisecond, "ms"}, 179 | {time.Microsecond, "micros"}, 180 | } 181 | 182 | // formatDurationElasticsearch formats a duration using 183 | // Elasticsearch supported time units. 184 | // 185 | // See https://www.elastic.co/guide/en/elasticsearch/reference/current/api-conventions.html#time-units 186 | func formatDurationElasticsearch(d time.Duration) string { 187 | for _, tu := range elasticsearchTimeUnits { 188 | if d%tu.Duration == 0 { 189 | return fmt.Sprintf("%d%s", d/tu.Duration, tu.Unit) 190 | } 191 | } 192 | return fmt.Sprintf("%dnanos", d) 193 | } 194 | -------------------------------------------------------------------------------- /pkg/approvaltest/approvals.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | // Package approvaltest contains helper functions to compare and assert 19 | // the received content of a test vs the accepted. 20 | package approvaltest 21 | 22 | import ( 23 | "encoding/json" 24 | "fmt" 25 | "os" 26 | "path/filepath" 27 | "sort" 28 | "strings" 29 | "testing" 30 | 31 | "github.com/google/go-cmp/cmp" 32 | "github.com/tidwall/gjson" 33 | "github.com/tidwall/sjson" 34 | 35 | "github.com/elastic/apm-tools/pkg/espoll" 36 | ) 37 | 38 | const ( 39 | // ApprovedSuffix signals a file has been reviewed and approved. 40 | ApprovedSuffix = ".approved.json" 41 | 42 | // ReceivedSuffix signals a file has changed and not yet been approved. 43 | ReceivedSuffix = ".received.json" 44 | ) 45 | 46 | // ApproveEvents compares the _source of the search hits with the 47 | // contents of the file in "approvals/.approved.json". 48 | // 49 | // Dynamic fields (@timestamp, observer.id, etc.) are replaced 50 | // with a static string for comparison. Integration tests elsewhere 51 | // use canned data to test fields that we do not cover here. 52 | // 53 | // If the events differ, then the test will fail. 54 | func ApproveEvents(t testing.TB, name string, hits []espoll.SearchHit, dynamic ...string) { 55 | t.Helper() 56 | 57 | sources := make([][]byte, len(hits)) 58 | for i, hit := range hits { 59 | sources[i] = hit.RawSource 60 | } 61 | rewriteDynamic(t, sources, false, dynamic...) 62 | // Rewrite dynamic fields and sort them for repeatable diffs. 63 | sort.Slice(sources, func(i, j int) bool { 64 | return compareDocumentFields(sources[i], sources[j]) < 0 65 | }) 66 | approveEventDocs(t, filepath.Join("approvals", name), sources) 67 | } 68 | 69 | // ApproveFields compares the fields of the search hits with the 70 | // contents of the file in "approvals/.approved.json". 71 | // 72 | // Dynamic fields (@timestamp, observer.id, etc.) are replaced 73 | // with a static string for comparison. Integration tests elsewhere 74 | // use canned data to test fields that we do not cover here. 75 | // 76 | // TODO(axw) eventually remove ApproveEvents when we have updated 77 | // all calls to use ApproveFields. ApproveFields should be used 78 | // since it includes runtime fields, whereas ApproveEvents only 79 | // looks at _source. 80 | func ApproveFields(t testing.TB, name string, hits []espoll.SearchHit, dynamic ...string) { 81 | t.Helper() 82 | 83 | fields := make([][]byte, len(hits)) 84 | for i, hit := range hits { 85 | fields[i] = hit.RawFields 86 | } 87 | // Rewrite dynamic fields and sort them for repeatable diffs. 88 | rewriteDynamic(t, fields, true, dynamic...) 89 | sort.Slice(fields, func(i, j int) bool { 90 | return compareDocumentFields(fields[i], fields[j]) < 0 91 | }) 92 | approveFields(t, filepath.Join("approvals", name), fields) 93 | } 94 | 95 | // rewriteDynamic rewrites all dynamic fields to have a known value, so dynamic 96 | // fields don't affect diffs. The flattenedKeys parameter defines how the 97 | // field should be queried in the source, if flattenedKeys is passed as true 98 | // then the source will be queried for the dynamic fields as flattened keys. 99 | func rewriteDynamic(t testing.TB, srcs [][]byte, flattenedKeys bool, dynamic ...string) { 100 | t.Helper() 101 | 102 | // Fields generated by the server (e.g. observer.*) 103 | // agent which may change between tests. 104 | // 105 | // Ignore their values in comparisons, but compare 106 | // existence: either the field exists in both, or neither. 107 | dynamic = append([]string{ 108 | "ecs.version", 109 | "event.ingested", 110 | "observer.ephemeral_id", 111 | "observer.hostname", 112 | "observer.id", 113 | "observer.version", 114 | }, dynamic...) 115 | 116 | for i := range srcs { 117 | for _, field := range dynamic { 118 | if flattenedKeys { 119 | field = strings.ReplaceAll(field, ".", "\\.") 120 | } 121 | existing := gjson.GetBytes(srcs[i], field) 122 | if !existing.Exists() { 123 | continue 124 | } 125 | 126 | var v interface{} 127 | if existing.IsArray() { 128 | v = []any{"dynamic"} 129 | } else { 130 | v = "dynamic" 131 | } 132 | 133 | var err error 134 | srcs[i], err = sjson.SetBytes(srcs[i], field, v) 135 | if err != nil { 136 | t.Fatal(err) 137 | } 138 | } 139 | } 140 | } 141 | 142 | // approveEventDocs compares the given event documents with 143 | // the contents of the file in ".approved.json". 144 | // 145 | // Any specified dynamic fields (e.g. @timestamp, observer.id) 146 | // will be replaced with a static string for comparison. 147 | // 148 | // If the events differ, then the test will fail. 149 | func approveEventDocs(t testing.TB, name string, eventDocs [][]byte) { 150 | t.Helper() 151 | 152 | events := make([]interface{}, len(eventDocs)) 153 | for i, doc := range eventDocs { 154 | 155 | var event map[string]interface{} 156 | if err := json.Unmarshal(doc, &event); err != nil { 157 | t.Fatal(err) 158 | } 159 | events[i] = event 160 | } 161 | 162 | received := map[string]interface{}{"events": events} 163 | approve(t, name, received) 164 | } 165 | 166 | func approveFields(t testing.TB, name string, docs [][]byte) { 167 | t.Helper() 168 | 169 | // Rewrite all dynamic fields to have a known value, 170 | // so dynamic fields don't affect diffs. 171 | decodedDocs := make([]any, len(docs)) 172 | for i, doc := range docs { 173 | var fields map[string]any 174 | if err := json.Unmarshal(doc, &fields); err != nil { 175 | t.Fatal(err) 176 | } 177 | decodedDocs[i] = fields 178 | } 179 | 180 | approve(t, name, decodedDocs) 181 | } 182 | 183 | // approve compares the given value with the contents of the file 184 | // ".approved.json". 185 | // 186 | // If the value differs, then the test will fail. 187 | func approve(t testing.TB, name string, received interface{}) { 188 | t.Helper() 189 | 190 | var approved interface{} 191 | if err := readApproved(name, &approved); err != nil { 192 | t.Fatalf("failed to read approved file: %v", err) 193 | } 194 | if diff := cmp.Diff(approved, received); diff != "" { 195 | if err := writeReceived(name, received); err != nil { 196 | t.Fatalf("failed to write received file: %v", err) 197 | } 198 | t.Fatalf("%s\n%s\n\n", diff, 199 | "Test failed. Run `make check-approvals` to verify the diff.", 200 | ) 201 | } else { 202 | // Remove an old *.received.json file if it exists, ignore errors 203 | _ = removeReceived(name) 204 | } 205 | } 206 | 207 | func readApproved(name string, approved interface{}) error { 208 | path := name + ApprovedSuffix 209 | f, err := os.Open(path) 210 | if err != nil && !os.IsNotExist(err) { 211 | return fmt.Errorf("failed to open approved file for %s: %w", name, err) 212 | } 213 | defer f.Close() 214 | if os.IsNotExist(err) { 215 | return nil 216 | } 217 | if err := json.NewDecoder(f).Decode(&approved); err != nil { 218 | return fmt.Errorf("failed to decode approved file for %s: %w", name, err) 219 | } 220 | return nil 221 | } 222 | 223 | func removeReceived(name string) error { 224 | return os.Remove(name + ReceivedSuffix) 225 | } 226 | 227 | func writeReceived(name string, received interface{}) error { 228 | fullpath := name + ReceivedSuffix 229 | if err := os.MkdirAll(filepath.Dir(fullpath), 0755); err != nil { 230 | return fmt.Errorf("failed to create directories for received file: %w", err) 231 | } 232 | f, err := os.Create(fullpath) 233 | if err != nil { 234 | return fmt.Errorf("failed to create received file for %s: %w", name, err) 235 | } 236 | defer f.Close() 237 | enc := json.NewEncoder(f) 238 | enc.SetIndent("", " ") 239 | if err := enc.Encode(received); err != nil { 240 | return fmt.Errorf("failed to encode received file for %s: %w", name, err) 241 | } 242 | return nil 243 | } 244 | -------------------------------------------------------------------------------- /pkg/tracegen/otlp.go: -------------------------------------------------------------------------------- 1 | // Licensed to Elasticsearch B.V. under one or more contributor 2 | // license agreements. See the NOTICE file distributed with 3 | // this work for additional information regarding copyright 4 | // ownership. Elasticsearch B.V. licenses this file to you under 5 | // the Apache License, Version 2.0 (the "License"); you may 6 | // not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, 12 | // software distributed under the License is distributed on an 13 | // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | // KIND, either express or implied. See the License for the 15 | // specific language governing permissions and limitations 16 | // under the License. 17 | 18 | package tracegen 19 | 20 | import ( 21 | "context" 22 | "crypto/tls" 23 | "errors" 24 | "fmt" 25 | "net" 26 | "net/url" 27 | "time" 28 | 29 | "go.opentelemetry.io/collector/pdata/pcommon" 30 | "go.opentelemetry.io/collector/pdata/plog" 31 | "go.opentelemetry.io/collector/pdata/plog/plogotlp" 32 | "go.opentelemetry.io/otel" 33 | "go.opentelemetry.io/otel/attribute" 34 | "go.opentelemetry.io/otel/exporters/otlp/otlptrace" 35 | "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" 36 | "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" 37 | "go.opentelemetry.io/otel/propagation" 38 | "go.opentelemetry.io/otel/sdk/resource" 39 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 40 | "go.opentelemetry.io/otel/trace" 41 | "google.golang.org/grpc" 42 | "google.golang.org/grpc/credentials" 43 | grpcinsecure "google.golang.org/grpc/credentials/insecure" 44 | "google.golang.org/grpc/metadata" 45 | ) 46 | 47 | // SendOTLPTrace sends spans, error and logs to the configured APM Server 48 | // If distributed tracing is needed, you might want to set up the propagator 49 | // using SetOTLPTracePropagator function before calling this function 50 | func SendOTLPTrace(ctx context.Context, cfg Config) (EventStats, error) { 51 | if err := cfg.validate(); err != nil { 52 | return EventStats{}, err 53 | } 54 | 55 | endpointURL, err := url.Parse(cfg.apmServerURL) 56 | if err != nil { 57 | return EventStats{}, fmt.Errorf("failed to parse endpoint: %w", err) 58 | } 59 | switch endpointURL.Scheme { 60 | case "http": 61 | if endpointURL.Port() == "" { 62 | endpointURL.Host = net.JoinHostPort(endpointURL.Host, "80") 63 | } 64 | case "https": 65 | if endpointURL.Port() == "" { 66 | endpointURL.Host = net.JoinHostPort(endpointURL.Host, "443") 67 | } 68 | default: 69 | return EventStats{}, fmt.Errorf("endpoint must be prefixed with http:// or https://") 70 | } 71 | 72 | otlpExporters, err := newOTLPExporters(ctx, endpointURL, cfg) 73 | if err != nil { 74 | return EventStats{}, err 75 | } 76 | defer otlpExporters.cleanup(ctx) 77 | 78 | resource := resource.NewSchemaless( 79 | attribute.String("service.name", cfg.otlpServiceName), 80 | ) 81 | tracerProvider := sdktrace.NewTracerProvider( 82 | sdktrace.WithSyncer(otlpExporters.trace), 83 | sdktrace.WithResource(resource), 84 | ) 85 | 86 | // generateSpans returns ctx that contains trace context 87 | var stats EventStats 88 | ctx, err = generateSpans(ctx, tracerProvider.Tracer("tracegen"), &stats) 89 | if err != nil { 90 | return EventStats{}, err 91 | } 92 | if err := generateLogs(ctx, otlpExporters.log, resource, &stats); err != nil { 93 | return EventStats{}, err 94 | } 95 | 96 | // Shutdown, flushing all data to the server. 97 | if err := tracerProvider.Shutdown(ctx); err != nil { 98 | return EventStats{}, err 99 | } 100 | if err := otlpExporters.cleanup(ctx); err != nil { 101 | return EventStats{}, err 102 | } 103 | return stats, nil 104 | } 105 | 106 | func generateSpans(ctx context.Context, tracer trace.Tracer, stats *EventStats) (context.Context, error) { 107 | now := time.Now() 108 | ctx, parent := tracer.Start(ctx, 109 | "parent", 110 | trace.WithSpanKind(trace.SpanKindServer), 111 | trace.WithTimestamp(now), 112 | ) 113 | defer parent.End(trace.WithTimestamp(now.Add(time.Millisecond * 1500))) 114 | stats.SpansSent++ 115 | 116 | _, child1 := tracer.Start(ctx, "child1", trace.WithTimestamp(now.Add(time.Millisecond*500))) 117 | time.Sleep(10 * time.Millisecond) 118 | child1.AddEvent("an arbitrary event") 119 | child1.End(trace.WithTimestamp(now.Add(time.Second * 1))) 120 | stats.SpansSent++ 121 | stats.LogsSent++ // span event is captured as a log 122 | 123 | _, child2 := tracer.Start(ctx, "child2", trace.WithTimestamp(now.Add(time.Millisecond*600))) 124 | time.Sleep(10 * time.Millisecond) 125 | child2.RecordError(errors.New("an exception occurred")) 126 | child2.End(trace.WithTimestamp(now.Add(time.Millisecond * 1300))) 127 | stats.SpansSent++ 128 | stats.ExceptionsSent++ // error captured as an error/exception log event 129 | 130 | return ctx, nil 131 | } 132 | 133 | func generateLogs(ctx context.Context, logger otlplogExporter, res *resource.Resource, stats *EventStats) error { 134 | logs := plog.NewLogs() 135 | rl := logs.ResourceLogs().AppendEmpty() 136 | attribs := rl.Resource().Attributes() 137 | for iter := res.Iter(); iter.Next(); { 138 | kv := iter.Attribute() 139 | switch typ := kv.Value.Type(); typ { 140 | case attribute.STRING: 141 | attribs.PutStr(string(kv.Key), kv.Value.AsString()) 142 | default: 143 | panic(fmt.Errorf("unhandled attribute type %q", typ)) 144 | } 145 | } 146 | 147 | sl := rl.ScopeLogs().AppendEmpty().LogRecords() 148 | record := sl.AppendEmpty() 149 | record.Body().SetStr("sample body value") 150 | record.SetTimestamp(pcommon.NewTimestampFromTime(time.Now())) 151 | record.SetSeverityNumber(plog.SeverityNumberFatal) 152 | record.SetSeverityText("fatal") 153 | stats.LogsSent++ 154 | return logger.Export(ctx, logs) 155 | } 156 | 157 | type otlpExporters struct { 158 | cleanup func(context.Context) error 159 | trace *otlptrace.Exporter 160 | log otlplogExporter 161 | } 162 | 163 | func newOTLPExporters(ctx context.Context, endpointURL *url.URL, cfg Config) (*otlpExporters, error) { 164 | switch cfg.otlpProtocol { 165 | case "grpc": 166 | return newOTLPGRPCExporters(ctx, endpointURL, cfg) 167 | case "http/protobuf": 168 | return newOTLPHTTPExporters(ctx, endpointURL, cfg) 169 | default: 170 | return nil, fmt.Errorf("invalid protocol %q", cfg.otlpProtocol) 171 | } 172 | } 173 | 174 | func newOTLPGRPCExporters(ctx context.Context, endpointURL *url.URL, cfg Config) (*otlpExporters, error) { 175 | var transportCredentials credentials.TransportCredentials 176 | 177 | switch endpointURL.Scheme { 178 | case "http": 179 | // If http:// is specified, then use insecure (plaintext). 180 | transportCredentials = grpcinsecure.NewCredentials() 181 | case "https": 182 | transportCredentials = credentials.NewTLS(&tls.Config{InsecureSkipVerify: cfg.insecure}) 183 | } 184 | 185 | grpcConn, err := grpc.NewClient( 186 | endpointURL.Host, 187 | grpc.WithTransportCredentials(transportCredentials), 188 | grpc.WithDefaultCallOptions(grpc.UseCompressor("gzip")), 189 | ) 190 | if err != nil { 191 | return nil, err 192 | } 193 | cleanup := func(context.Context) error { 194 | return grpcConn.Close() 195 | } 196 | 197 | traceOptions := []otlptracegrpc.Option{otlptracegrpc.WithGRPCConn(grpcConn)} 198 | var logHeaders map[string]string 199 | headers := map[string]string{"Authorization": "ApiKey " + cfg.apiKey} 200 | traceOptions = append(traceOptions, otlptracegrpc.WithHeaders(headers)) 201 | logHeaders = headers 202 | 203 | otlpTraceExporter, err := otlptracegrpc.New(ctx, traceOptions...) 204 | if err != nil { 205 | cleanup(ctx) 206 | return nil, err 207 | } 208 | cleanup = combineCleanup(otlpTraceExporter.Shutdown, cleanup) 209 | 210 | return &otlpExporters{ 211 | cleanup: cleanup, 212 | trace: otlpTraceExporter, 213 | log: &otlploggrpcExporter{ 214 | client: plogotlp.NewGRPCClient(grpcConn), 215 | headers: logHeaders, 216 | }, 217 | }, nil 218 | } 219 | 220 | func newOTLPHTTPExporters(ctx context.Context, endpointURL *url.URL, cfg Config) (*otlpExporters, error) { 221 | tlsConfig := &tls.Config{InsecureSkipVerify: cfg.insecure} 222 | traceOptions := []otlptracehttp.Option{ 223 | otlptracehttp.WithEndpoint(endpointURL.Host), 224 | otlptracehttp.WithTLSClientConfig(tlsConfig), 225 | } 226 | if endpointURL.Scheme == "http" { 227 | traceOptions = append(traceOptions, otlptracehttp.WithInsecure()) 228 | } 229 | 230 | headers := map[string]string{"Authorization": "ApiKey " + cfg.apiKey} 231 | traceOptions = append(traceOptions, otlptracehttp.WithHeaders(headers)) 232 | 233 | cleanup := func(context.Context) error { return nil } 234 | 235 | otlpTraceExporter, err := otlptracehttp.New(ctx, traceOptions...) 236 | if err != nil { 237 | cleanup(ctx) 238 | return nil, err 239 | } 240 | cleanup = combineCleanup(otlpTraceExporter.Shutdown, cleanup) 241 | 242 | return &otlpExporters{ 243 | cleanup: cleanup, 244 | trace: otlpTraceExporter, 245 | log: &otlploghttpExporter{}, 246 | }, nil 247 | } 248 | 249 | func combineCleanup(a, b func(context.Context) error) func(context.Context) error { 250 | return func(ctx context.Context) error { 251 | if err := a(ctx); err != nil { 252 | return err 253 | } 254 | return b(ctx) 255 | } 256 | } 257 | 258 | type otlplogExporter interface { 259 | Export(ctx context.Context, logs plog.Logs) error 260 | } 261 | 262 | // otlploggrpcExporter is a simple synchronous log exporter using GRPC 263 | type otlploggrpcExporter struct { 264 | client plogotlp.GRPCClient 265 | headers map[string]string 266 | } 267 | 268 | func (e *otlploggrpcExporter) Export(ctx context.Context, logs plog.Logs) error { 269 | req := plogotlp.NewExportRequestFromLogs(logs) 270 | md := metadata.New(e.headers) 271 | ctx = metadata.NewOutgoingContext(ctx, md) 272 | 273 | _, err := e.client.Export(ctx, req) 274 | if err != nil { 275 | return err 276 | } 277 | 278 | // TODO: parse response for error 279 | return nil 280 | } 281 | 282 | // otlploghttpExporter is a simple synchronous log exporter using protobuf over HTTP 283 | type otlploghttpExporter struct { 284 | } 285 | 286 | func (e *otlploghttpExporter) Export(ctx context.Context, logs plog.Logs) error { 287 | // TODO: implement 288 | return errors.New("otlploghttpExporter isn't implemented") 289 | } 290 | 291 | func SetOTLPTracePropagator(ctx context.Context, traceparent string, tracestate string) context.Context { 292 | m := propagation.MapCarrier{} 293 | m.Set("traceparent", traceparent) 294 | m.Set("tracestate", tracestate) 295 | tc := propagation.TraceContext{} 296 | // Register the TraceContext propagator globally. 297 | otel.SetTextMapPropagator(tc) 298 | 299 | return tc.Extract(ctx, m) 300 | } 301 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2018 Elasticsearch BV 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= 2 | github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 3 | github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= 4 | github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= 5 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 6 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/elastic/elastic-transport-go/v8 v8.8.0 h1:7k1Ua+qluFr6p1jfJjGDl97ssJS/P7cHNInzfxgBQAo= 11 | github.com/elastic/elastic-transport-go/v8 v8.8.0/go.mod h1:YLHer5cj0csTzNFXoNQ8qhtGY1GTvSqPnKWKaqQE3Hk= 12 | github.com/elastic/go-elasticsearch/v8 v8.19.1 h1:0iEGt5/Ds9MNVxEp3hqLsXdbe6SjleaVHONg/FuR09Q= 13 | github.com/elastic/go-elasticsearch/v8 v8.19.1/go.mod h1:tHJQdInFa6abmDbDCEH2LJja07l/SIpaGpJcm13nt7s= 14 | github.com/elastic/go-sysinfo v1.15.0 h1:54pRFlAYUlVNQ2HbXzLVZlV+fxS7Eax49stzg95M4Xw= 15 | github.com/elastic/go-sysinfo v1.15.0/go.mod h1:jPSuTgXG+dhhh0GKIyI2Cso+w5lPJ5PvVqKlL8LV/Hk= 16 | github.com/elastic/go-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI= 17 | github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8= 18 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 19 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 20 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 21 | github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 22 | github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 23 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 24 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 25 | github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= 26 | github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= 27 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 28 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 29 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 30 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 31 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 32 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 33 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 34 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= 35 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= 36 | github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= 37 | github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 38 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 39 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 40 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 41 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 42 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 43 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 44 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 45 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 46 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 47 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 48 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 49 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 50 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 51 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 52 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 53 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 54 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= 55 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 56 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 57 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 58 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 59 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 60 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 61 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 62 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 63 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 64 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 65 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 66 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 67 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 68 | github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 69 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 70 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 71 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 72 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 73 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 74 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 75 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 76 | github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= 77 | github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 78 | github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo= 79 | github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= 80 | go.elastic.co/apm/module/apmhttp/v2 v2.7.2 h1:grLycchDH4B6aGRkZjIV/sweAivJDl8IcP+nCorktm8= 81 | go.elastic.co/apm/module/apmhttp/v2 v2.7.2/go.mod h1:N9CJn3x7cyFnZ54WKxgm/t76drcsmSpu6aU8zGwP4zQ= 82 | go.elastic.co/apm/module/apmotel/v2 v2.7.2 h1:cAs0vv6laivMlhPGrgsdTqoG1u6fuiDctoWizHaqQOA= 83 | go.elastic.co/apm/module/apmotel/v2 v2.7.2/go.mod h1:CwPbr5N0/9xS8MNwxxGigoxM97vfFhKB/byefKVKt7Y= 84 | go.elastic.co/apm/v2 v2.7.2 h1:0blxpxOMOcpBTz034RBqvEw806y0CDJwo/ut+2wZsHA= 85 | go.elastic.co/apm/v2 v2.7.2/go.mod h1:KJcwwsaouDzcLd8EviAO+y8yrfZzD6PhUCEg82bvLV4= 86 | go.elastic.co/fastjson v1.5.1 h1:zeh1xHrFH79aQ6Xsw7YxixvnOdAl3OSv0xch/jRDzko= 87 | go.elastic.co/fastjson v1.5.1/go.mod h1:WtvH5wz8z9pDOPqNYSYKoLLv/9zCWZLeejHWuvdL/EM= 88 | go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= 89 | go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 90 | go.opentelemetry.io/collector/featuregate v1.48.0 h1:jiGRcl93yzUFgZVDuskMAftFraE21jANdxXTQfSQScc= 91 | go.opentelemetry.io/collector/featuregate v1.48.0/go.mod h1:/1bclXgP91pISaEeNulRxzzmzMTm4I5Xih2SnI4HRSo= 92 | go.opentelemetry.io/collector/internal/testutil v0.142.0 h1:MHnAVRimQdsfYqYHC3YuJRkIUap4VmSpJkkIT2N7jJA= 93 | go.opentelemetry.io/collector/internal/testutil v0.142.0/go.mod h1:YAD9EAkwh/l5asZNbEBEUCqEjoL1OKMjAMoPjPqH76c= 94 | go.opentelemetry.io/collector/pdata v1.48.0 h1:CKZ+9v/lGTX/cTGx2XVp8kp0E8R//60kHFCBdZudrTg= 95 | go.opentelemetry.io/collector/pdata v1.48.0/go.mod h1:jaf2JQGpfUreD1TOtGBPsq00ecOqM66NG15wALmdxKA= 96 | go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= 97 | go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= 98 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 h1:cEf8jF6WbuGQWUVcqgyWtTR0kOOAWY1DYZ+UhvdmQPw= 99 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0/go.mod h1:k1lzV5n5U3HkGvTCJHraTAGJ7MqsgL1wrGwTj1Isfiw= 100 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0 h1:nKP4Z2ejtHn3yShBb+2KawiXgpn8In5cT7aO2wXuOTE= 101 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0/go.mod h1:NwjeBbNigsO4Aj9WgM0C+cKIrxsZUaRmZUO7A8I7u8o= 102 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= 103 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= 104 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= 105 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= 106 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= 107 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= 108 | go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= 109 | go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= 110 | go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= 111 | go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= 112 | go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= 113 | go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= 114 | go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= 115 | go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= 116 | go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= 117 | go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= 118 | go.opentelemetry.io/proto/slim/otlp v1.9.0 h1:fPVMv8tP3TrsqlkH1HWYUpbCY9cAIemx184VGkS6vlE= 119 | go.opentelemetry.io/proto/slim/otlp v1.9.0/go.mod h1:xXdeJJ90Gqyll+orzUkY4bOd2HECo5JofeoLpymVqdI= 120 | go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0 h1:o13nadWDNkH/quoDomDUClnQBpdQQ2Qqv0lQBjIXjE8= 121 | go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0/go.mod h1:Gyb6Xe7FTi/6xBHwMmngGoHqL0w29Y4eW8TGFzpefGA= 122 | go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.2.0 h1:EiUYvtwu6PMrMHVjcPfnsG3v+ajPkbUeH+IL93+QYyk= 123 | go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.2.0/go.mod h1:mUUHKFiN2SST3AhJ8XhJxEoeVW12oqfXog0Bo8W3Ec4= 124 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 125 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 126 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 127 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 128 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 129 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 130 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 131 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 132 | golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 133 | golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 134 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 135 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 136 | gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= 137 | gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= 138 | google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= 139 | google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= 140 | google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= 141 | google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= 142 | google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= 143 | google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= 144 | google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= 145 | google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 146 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 147 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 148 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 149 | gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= 150 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 151 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 152 | howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= 153 | howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= 154 | --------------------------------------------------------------------------------