Repository: equinix-labs/otel-cli Branch: main Commit: 8f86e487b5f5 Files: 62 Total size: 299.3 KB Directory structure: gitextract_c7r88qzy/ ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── .goreleaser.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── TESTING.md ├── configs/ │ ├── otel-collector.yaml │ └── otel-vendor-config.yaml ├── data_for_test.go ├── demos/ │ ├── 01-simple-span.sh │ ├── 05-nested-exec.sh │ ├── 10-span-background-simple.sh │ ├── 15span-background-layered.sh │ ├── 20span-background-race-workarounds.sh │ ├── 25srecon22-talk-agenda.sh │ └── 30-trace-build-process/ │ └── otel-wrapper-shim.sh ├── docker-compose.yaml ├── example-config.json ├── go.mod ├── go.sum ├── main.go ├── main_test.go ├── otelcli/ │ ├── completion.go │ ├── config.go │ ├── config_span.go │ ├── config_span_test.go │ ├── config_test.go │ ├── config_tls.go │ ├── diagnostics.go │ ├── exec.go │ ├── otlpclient.go │ ├── root.go │ ├── server.go │ ├── server_json.go │ ├── server_tui.go │ ├── span.go │ ├── span_background.go │ ├── span_background_server.go │ ├── span_end.go │ ├── span_event.go │ ├── status.go │ ├── version.go │ └── version_test.go ├── otlpclient/ │ ├── otlp_client.go │ ├── otlp_client_grpc.go │ ├── otlp_client_grpc_test.go │ ├── otlp_client_http.go │ ├── otlp_client_http_test.go │ ├── otlp_client_null.go │ ├── otlp_client_test.go │ ├── protobuf_span.go │ └── protobuf_span_test.go ├── otlpserver/ │ ├── grpcserver.go │ ├── httpserver.go │ └── server.go ├── release/ │ └── Dockerfile ├── renovate.json ├── tls_for_test.go └── w3c/ └── traceparent/ ├── traceparent.go └── traceparent_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: [ main ] pull_request: jobs: build: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 - name: Setup uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Get dependencies run: go mod download # otel-cli's main test needs the binary built ahead of time # also this validates it can acutally build before we get there - name: Build # build with -s -w to reduce binary size and verify that build in test run: go build -v -ldflags="-s -w -X main.version=test -X main.commit=${{ github.sha }}" - name: Test run: go test -v -cover -parallel 4 ./... ================================================ FILE: .gitignore ================================================ # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib otel-cli # Test binary, built with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Dependency directories (remove the comment below to include it) # vendor/ dist/ *.pem ================================================ FILE: .goreleaser.yml ================================================ # last updated for goreleaser v1.14.1 before: hooks: - go mod tidy checksum: name_template: 'checksums.txt' snapshot: name_template: 'SNAPSHOT-{{ .Commit }}' changelog: sort: asc filters: exclude: - '^demos:' - '^configs:' - Merge pull request - Merge branch - go mod tidy builds: - env: - CGO_ENABLED=0 goos: - linux - windows - darwin - freebsd goarch: - amd64 - arm64 - 386 goarm: - 7 ignore: - goos: darwin goarch: 386 - goos: freebsd goarch: arm64 mod_timestamp: "{{ .CommitTimestamp }}" ldflags: - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{ .CommitDate }} nfpms: - package_name: otel-cli homepage: https://github.com/equinix-labs/otel-cli maintainer: Amy Tobey description: OpenTelemetry CLI Application (Server & Client) license: Apache 2.0 formats: - apk - deb - rpm archives: - format: tar.gz format_overrides: - goos: windows format: zip builds_info: group: root owner: root rlcp: true brews: # This means the repository must be equinix-labs/homebrew-otel-cli - name: "otel-cli" url_template: "https://github.com/equinix-labs/otel-cli/releases/download/{{ .Tag }}/{{ .ArtifactName }}" tap: owner: "equinix-labs" name: "homebrew-otel-cli" token: "{{ .Env.GITHUB_TOKEN }}" commit_author: name: "tobert" email: "atobey@equinix.com" homepage: "https://github.com/equinix-labs/otel-cli" description: "OpenTelemetry command-line tool for sending events from shell scripts & similar environments" license: "Apache-2.0" # If set to auto, the release will not be uploaded to the homebrew tap # in case there is an indicator for prerelease in the tag e.g. v1.0.0-rc1 skip_upload: "auto" dockers: - image_templates: - "ghcr.io/equinix-labs/otel-cli:{{ .Tag }}-amd64" dockerfile: release/Dockerfile use: buildx build_flag_templates: - "--pull" - "--label=org.opencontainers.image.created={{.Date}}" - "--label=org.opencontainers.image.name={{.ProjectName}}" - "--label=org.opencontainers.image.revision={{.FullCommit}}" - "--label=org.opencontainers.image.version={{.Version}}" - "--label=org.opencontainers.image.source={{.GitURL}}" - "--platform=linux/amd64" - image_templates: - "ghcr.io/equinix-labs/otel-cli:{{ .Tag }}-arm64v8" dockerfile: release/Dockerfile use: buildx build_flag_templates: - "--pull" - "--label=org.opencontainers.image.created={{.Date}}" - "--label=org.opencontainers.image.name={{.ProjectName}}" - "--label=org.opencontainers.image.revision={{.FullCommit}}" - "--label=org.opencontainers.image.version={{.Version}}" - "--label=org.opencontainers.image.source={{.GitURL}}" - "--platform=linux/arm64/v8" docker_manifests: - name_template: "ghcr.io/equinix-labs/otel-cli:{{ .Tag }}" image_templates: - "ghcr.io/equinix-labs/otel-cli:{{ .Tag }}-amd64" - "ghcr.io/equinix-labs/otel-cli:{{ .Tag }}-arm64v8" - name_template: "ghcr.io/equinix-labs/otel-cli:latest" image_templates: - "ghcr.io/equinix-labs/otel-cli:{{ .Tag }}-amd64" - "ghcr.io/equinix-labs/otel-cli:{{ .Tag }}-arm64v8" use: docker ================================================ FILE: CHANGELOG.md ================================================ ## [0.4.6] - 2024-05-13 Build smaller binaries and add version subcommand. ### Added - `otel-cli version` will now report version information of the binary (if any) ### Changed - linker flags now contain `-s -w` to reduce binary size - change made in build Dockerfile, goreleaser, and GH Actions - contribution from @Ipmi-13, thank you! - goreleaser now does the -X flags so `otel-cli version` will work right - removed Content-Length from functional tests bc it's not fixed with gzip in play - updated demos, added one - updated instructions in README.md ## [0.4.5] - 2024-01-01 Fix exec attributes, cleanups, and dependency updates. `otel-cli exec` attributes were broken for the last few releases so @tobert felt it was ok to rename them to match the OTel semantic conventions now. ### Changed - using latest deps for grpc, protobuf, and otel - exec attributes will now go out with the span - exec now sends process.command and process.command_args attributes - main_test.go now supports regular expression tests on span data ## [0.4.4] - 2024-03-11 Fix a typo in the `OTEL_CLI_EXEC_TP_DISABLE_INJECT` envvar. ### Changed - spell `DISALBE` correctly in `OTEL_CLI_EXEC_TP_DISABLE_INJECT` - adds a test for that ## [0.4.3] - 2024-03-11 Add injection of `{{traceparent}}` to `otel-cli exec` as default behavior, along with the `otel-cli exec --tp-disable-inject` to turn it off (old behavior). ### Added - `otel-cli exec echo {{traceparent}}` is now supported to pass traceparent to child process - `otel-cli exec --tp-disable-inject` will disable this new default behavior ## [0.4.2] - 2023-12-01 The Docker container now builds off `alpine:latest` instead of `scratch`. This makes the default certificate store included with Alpine available to otel-cli. ### Changed - switch release Dockerfile to base off alpine:latest ## [0.4.1] - 2023-10-16 Mostly small but impactful changes to `otel-cli exec`. ### Added - `otel-cli exec --command-timeout 30s` provides a separate command timeout from the otel timeout - SIGINT is now caught and passed to the child process - attributes can be set or overwrite on a backgrounded span via `otel-cli span end` ### Changed - bumped several dependencies to the latest release - updated README.md ## [0.4.0] - 2023-08-09 This focus of this release is a brand-new OTLP client implementation. It has fewer features than the opentelemetry-collector code, and allows for more fine-grained control over how gRPC and HTTP are configured. Along the way, the `otelcli` and `otlpclient` packages went through a couple refactors to organize code better in preparation for adding metrics and logs, hopefully in 0.5.0. ### Added - `--force-parent-span-id` allows forcing the span parent (thanks @domofactor!) - `otel-cli status` now includes a list of errors including retries that later succeeded ### Changed - `--otlp-blocking` is marked deprecated and no longer does anything - the OTLP client implementation is no longer using opentelemetry-collector - traceparent code is now in a w3c/traceparent package - otlpserver.CliEvent is removed entirely, preferring protobuf spans & events ## [0.3.0] - 2023-05-26 The most important change is that `otel-cli exec` now treats arguments as an argv list instead of munging them into a string. This shouldn't break anyone doing sensible things, but could if folks were expecting the old `sh -c` behavior. Envvars are no longer deleted before calling exec. Actually, they still are, but otel-cli backs up its envvars early so they can be propagated anyways. The rest of the visible changes are incremental additions or fixes. As for the invisible, otel-cli now generates span protobufs directly, and no longer goes through the opentelemetry-go SDK. This ended up making some code cleaner, aside from some of the protobuf-isms that show up. A user has a use case for setting custom trace and span ids, so the `--force-trace-id` and `--force-span-id` options were added to `span`, `exec`, and other span-generating subcommands. ### Added - `--force-trace-id` and `--force-span-id` - `--status-code` and `--status-description` are supported, including on `otel-cli span background end` - demo for working around race conditions when using `span background` - more functional tests, including some regression tests - functional test harness now has a way to do custom checks via CheckFuncs - added this changelog ### Changed - (behavior) reverted envvar deletion so envvars will propagate through `exec` again - (behavior) exec argument handling is now precise and no longer uses `sh -c` - build now requires Go 1.20 or greater - otel-cli now generates span protobufs directly instead of using opentelemetry-go - respects signal-specific configs per OTel spec - handle endpoint configs closer to spec - lots of little cleanups across the code and docs - many dependency updates ## [0.2.0] 2023-02-27 The main addition in this version is client mTLS authentication support, which comes in with extensive e2e tests for TLS settings. `--no-tls-verify` is deprecated in favor of `--tls-no-verify` so all the TLS options are consistent. `otel-cli span background` now has a `--background-skip-parent-pid-check` option for some use cases where folks want otel-cli to keep running past its parent process. ### Changed - 52f1143 #11 support OTEL_SERVICE_NAME per spec (#158) - ed4bf2f Bump golang.org/x/net from 0.5.0 to 0.7.0 (#159) - 5c5865c Make configurable skipping pid check in span background command (#161) - 7214b64 Replace Jaeger instructions with otel-desktop-viewer (#162) - 6018f76 TLS naming cleanup (#166) - 9a7de86 add TLS testing and client certificate auth (#150) - 759fbef miscellaneous documentation fixes (#165) - f5286c0 never allow insecure automatically for https:// URIs (#149) ## [0.1.0] 2023-02-02 Apologies for the very long delay between releases. There is a lot of pent-up change in this release. Bumped minor version to 0.1 because there are some changes in behavior around endpoint URI handling and configuration. Also some inconsistencies in command line arguments has been touched up, so some uses of single-letter flags and `--ignore-tp-env` (renamed to `--tp-ignore-env to match other flags) might break. Viper has been dropped in favor of directly loading configuration from json and environment variables. It appears none of the Viper features ever worked in otel-cli so it shouldn't be a big deal, but if you were using Viper configs they won't work anymore and you'll have to switch to otel-cli's json config format. Endpoints now conform mostly to the OTel spec, except for a couple cases documented in the README.md. ### Changed - 4256644 #108 fix span background attrs (#116) - e8b86f6 #142 follow spec for OTLP protocol (#148) - efb5608 #42 add version subcommand (#114) - 007f8f7 Add renovate.json (#123) - 9d7a668 Make the service/name CLI short args match the long args (#110) - b164427 add http testing (#143) - 72df644 docs: --ignore-tp-env replace field to --tp-ignore-env (#147) - e48e468 feat: add span status code/description cli and env var (#111) - ce850f4 make grpc server stop more robust (#122) - 8eb37fb remove viper, fix tests, fix and expand envvars (#120) - ff5a4eb update OTel to 1.4.1 (#107) - b51d6fc update goreleaser config, add release procedure in README.md (#141) - 99c9242 update opentelemetry SDK to 1.11.2 (#138) ## [0.0.x] - 2021-03-24 - 2022-02-24 Developing the base functionality. Light on testing. ### Changed - added OTLP test server - added goreleaser - added timeouts - many refactors while discovering the shape of the tool - switch to failing silently - added subcommand to generate autocomplete data - added status subcommand - added functional test harness - added HTTP support ================================================ FILE: Dockerfile ================================================ FROM golang:latest AS builder WORKDIR /build COPY . . ENV CGO_ENABLED=0 RUN go build -ldflags="-w -s" -o otel-cli . FROM scratch AS otel-cli COPY --from=builder /build/otel-cli /otel-cli ENTRYPOINT ["/otel-cli"] ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2020 Packet Host, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # otel-cli [![](https://img.shields.io/badge/stability-experimental-lightgrey.svg)](https://github.com/packethost/standards/blob/master/experimental-statement.md) otel-cli is a command-line tool for sending OpenTelemetry traces. It is written in Go and intended to be used in shell scripts and other places where the best option available for sending spans is executing another program. otel-cli can be added to your scripts with no configuration and it will run as normal but in non-recording mode and will emit no traces. This follows the OpenTelemetry community's philosophy of "first, do no harm" and makes it so you can add otel-cli to your code and later turn it on. Since otel-cli needs to connect to the OTLP endpoint on each run, it is highly recommended to use a localhost opentelemetry collector that can buffer spans so that the connection cost does not slow down your program too much. ## Getting Started We publish a number of package formats for otel-cli, including tar.gz, zip (windows), apk (Alpine), rpm (Red Hat variants), deb (Debian variants), and a brew tap. These can be found on the repo's [Releases](https://github.com/equinix-labs/otel-cli/releases) page. On most platforms the easiest way is a go get: ```shell go install github.com/equinix-labs/otel-cli@latest ``` Docker images are published for each otel-cli release as well: ```shell docker pull ghcr.io/equinix-labs/otel-cli:latest docker run ghcr.io/equinix-labs/otel-cli:latest status ``` To use the brew tap e.g. on MacOS: ```shell brew tap equinix-labs/otel-cli brew install otel-cli ``` Alternatively, clone the repo and build it locally: ```shell git clone git@github.com:equinix-labs/otel-cli.git cd otel-cli go build ``` ## Examples ```shell # run otel-cli as a local OTLP server and print traces to your console # run this in its own terminal and try some of the commands below! otel-cli server tui # configure otel-cli to talk the the local server spawned above export OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317 # run a program inside a span otel-cli exec --service my-service --name "curl google" curl https://google.com # otel-cli propagates context via envvars so you can chain it to create child spans otel-cli exec --kind producer -- otel-cli exec --kind consumer sleep 1 # if a traceparent envvar is set it will be automatically picked up and # used by span and exec. use --tp-ignore-env to ignore it even when present export TRACEPARENT=00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01 # you can pass the traceparent to a child via arguments as well # {{traceparent}} in any of the command's arguments will be replaced with the traceparent string otel-cli exec --name "curl api" -- \ curl -H 'traceparent: {{traceparent}}' https://myapi.com/v1/coolstuff # create a span with a custom start/end time using either RFC3339, # same with the nanosecond extension, or Unix epoch, with/without nanos otel-cli span --start 2021-03-24T07:28:05.12345Z --end 2021-03-24T07:30:08.0001Z otel-cli span --start 1616620946 --end 1616620950.241980634 # so you can do this: start=$(date --rfc-3339=ns) # rfc3339 with nanoseconds some-interesting-program --with-some-options end=$(date +%s.%N) # Unix epoch with nanoseconds otel-cli span -n my-script -s some-interesting-program --start $start --end $end # for advanced cases you can start a span in the background, and # add events to it, finally closing it later in your script sockdir=$(mktemp -d) otel-cli span background \ --service $0 \ --name "$0 runtime" \ --sockdir $sockdir & # the & is important here, background server will block sleep 0.1 # give the background server just a few ms to start up otel-cli span event --name "cool thing" --attrs "foo=bar" --sockdir $sockdir otel-cli span end --sockdir $sockdir # or you can kill the background process and it will end the span cleanly kill %1 # server mode can also write traces to the filesystem, e.g. for testing dir=$(mktemp -d) otel-cli server json --dir $dir --timeout 60 --max-spans 5 ``` ## Configuration Everything is configurable via CLI arguments, json config, and environment variables. If no endpoint is specified, otel-cli will run in non-recording mode and not attempt to contact any servers. All three modes of config can be mixed. Command line args are loaded first, then config file, then environment variables. | CLI argument | environment variable | config file key | example value | | -------------------- | ------------------------------------- | ------------------------ | -------------- | | --endpoint | OTEL_EXPORTER_OTLP_ENDPOINT | endpoint | localhost:4317 | | --traces-endpoint | OTEL_EXPORTER_OTLP_TRACES_ENDPOINT | traces_endpoint | https://localhost:4318/v1/traces | | --protocol | OTEL_EXPORTER_OTLP_PROTOCOL | protocol | http/protobuf | | --insecure | OTEL_EXPORTER_OTLP_INSECURE | insecure | false | | --timeout | OTEL_EXPORTER_OTLP_TIMEOUT | timeout | 1s | | --otlp-headers | OTEL_EXPORTER_OTLP_HEADERS | otlp_headers | k=v,a=b | | --otlp-blocking | OTEL_EXPORTER_OTLP_BLOCKING | otlp_blocking | false | | --config | OTEL_CLI_CONFIG_FILE | config_file | config.json | | --verbose | OTEL_CLI_VERBOSE | verbose | false | | --fail | OTEL_CLI_FAIL | fail | false | | --service | OTEL_SERVICE_NAME | service_name | myapp | | --kind | OTEL_CLI_TRACE_KIND | span_kind | server | | --status-code | OTEL_CLI_STATUS_CODE | span_status_code | error | | --status-description | OTEL_CLI_STATUS_DESCRIPTION | span_status_description | cancelled | | --attrs | OTEL_CLI_ATTRIBUTES | span_attributes | k=v,a=b | | --force-trace-id | OTEL_CLI_FORCE_TRACE_ID | force_trace_id | 00112233445566778899aabbccddeeff | | --force-span-id | OTEL_CLI_FORCE_SPAN_ID | force_span_id | beefcafefacedead | | --force-parent-span-id | OTEL_CLI_FORCE_PARENT_SPAN_ID | force_parent_span_id | eeeeeeb33fc4f3d3 | | --tp-required | OTEL_CLI_TRACEPARENT_REQUIRED | traceparent_required | false | | --tp-carrier | OTEL_CLI_CARRIER_FILE | traceparent_carrier_file | filename.txt | | --tp-ignore-env | OTEL_CLI_IGNORE_ENV | traceparent_ignore_env | false | | --tp-print | OTEL_CLI_PRINT_TRACEPARENT | traceparent_print | false | | --tp-export | OTEL_CLI_EXPORT_TRACEPARENT | traceparent_print_export | false | | --tls-no-verify | OTEL_CLI_TLS_NO_VERIFY | tls_no_verify | false | | --tls-ca-cert | OTEL_EXPORTER_OTLP_CERTIFICATE | tls_ca_cert | /ca/ca.pem | | --tls-client-key | OTEL_EXPORTER_OTLP_CLIENT_KEY | tls_client_key | /keys/client-key.pem | | --tls-client-cert | OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE | tls_client_cert | /keys/client-cert.pem | [Valid timeout units](https://pkg.go.dev/time#ParseDuration) are "ns", "us"/"µs", "ms", "s", "m", "h". ### Endpoint URIs otel-cli deviates from the OTel specification for endpoint URIs. Mainly, otel-cli supports bare host:port for grpc endpoints and continues to default to gRPC. The optional http/json is not supported by opentelemetry-go so otel-cli does not support it. To use gRPC with an http endpoint, set the protocol with --protocol or the envvar. * bare `host:port` endpoints are assumed to be gRPC and are not supported for HTTP * `http://` and `https://` are assumed to be HTTP unless --protocol is set to `grpc`. * loopback addresses without an https:// prefix are assumed to be unencrypted ### Header and Attribute formatting Headers and attributes allow for `key=value,k=v` style formatting. Internally both otel-cli and pflag use Go's `encoding/csv` to parse these values. Therefore, if you want to pass commas in a value, follow CSV quoting rules and quote the whole k=v pair. Double quotes need to be escaped so the shell doesn't interpolate them. Once that's done, embedding commas will work fine. ```shell otel-cli span --attrs item1=value1,\"item2=value2,value3\",item3=value4 otel-cli span --attrs 'item1=value1,"item2=value2,value3",item3=value4' ``` ### Docker TLS Certificates As of release 0.4.2, otel-cli containers are built off the latest Alpine base image which contains the base CA certificate bundles. In order to override these for e.g. a self-signed certificate, the best bet is to volume mount your own /etc/ssl into the container, and it should get picked up by otel-cli and Go's TLS libraries. ```shell docker run -v /etc/ssl:/etc/ssl ghcr.io/equinix-labs/otel-cli:latest status ``` ## Easy local dev We want working on otel-cli to be easy, so we've provided a few different ways to get started. In general, there are three things you need: - A working Go environment - A built (or installed) copy of otel-cli itself - A system to receive/inspect the traces you generate ### 1. A working Go environment Providing instructions on getting Go up and running on your machine is out of scope for this README. However, the good news is that it's fairly easy to do! You can follow the normal [Installation instructions](https://golang.org/doc/install) from the Go project itself. ### 2. A built (or installed) copy of otel-cli itself If you're planning on making changes to otel-cli, we recommend building the project locally: `go build` But, if you just want to quickly try out otel-cli, you can also just install it directly: `go get github.com/equinix-labs/otel-cli`. This will place the command in your `GOPATH`. If your `GOPATH` is in your `PATH` you should be all set. ### 3. A system to receive/inspect the traces you generate otel-cli can run as a server and accept OTLP connections. It has two modes, one prints to your console while the other writes to JSON files. ```shell otel-cli server tui otel-cli server json --dir $dir --timeout 60 --max-spans 5 ``` Many SaaS vendors accept OTLP these days so one option is to send directly to those. This is not recommended for production since it will slow your code down on the roundtrips. It is recommended to use an opentelemetry-collector locally. Another option is to use [`otel-desktop-viewer`](https://github.com/CtrlSpice/otel-desktop-viewer). This will bring up a server that can accept OTLP connections. If you're not sure what to choose, try `otel-cli server tui` or `otel-desktop-viewer`. ### `otel-desktop-viewer` setup ```shell # install the CLI tool go install github.com/CtrlSpice/otel-desktop-viewer@latest # run it! $(go env GOPATH)/bin/otel-desktop-viewer # if you have $GOPATH/bin added to your $PATH you can call it directly! otel-desktop-viewer # if not you can add it to your $PATH by running this or adding it to # your startup script (usually ~/.bashrc or ~/.zshrc) export PATH="$(go env GOPATH)/bin:$PATH" ``` The OpenTelemetry collector is listening on `localhost:4318`, and the UI will be running on `localhost:8000`. ```shell # start the desktop viewer (best to do this in a separate terminal) otel-desktop-viewer # configure otel-cli to send to our desktop viewer endpoint export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 # use otel-cli to generate spans! otel-cli exec --service my-service --name "curl google" curl https://google.com ``` This trace will be available at `localhost:8000`. ### SaaS tracing vendor We've provided Honeycomb, LightStep, and Elastic configurations that you could also use, if you're using one of those vendors today. It's still pretty easy to get started: ```shell # optional: to send data to an an OTLP-enabled tracing vendor, pass in your # API auth token over an environment variable and modify # `local/otel-vendor-config.yaml` according to the comments inside export LIGHTSTEP_TOKEN= # Lightstep API key (otlp/1 in the yaml) export HONEYCOMB_TEAM= # Honeycomb API key (otlp/2 in the yaml) export ELASTIC_TOKEN= # Elastic token for the APM server. docker run \ --env LIGHTSTEP_TOKEN \ --env HONEYCOMB_TEAM \ --env ELASTIC_TOKEN \ --name otel-collector \ --net host \ --volume $(pwd)/configs/otel-vendor-config.yaml:/local.yaml \ otel/opentelemetry-collector-contrib:0.101.0 \ --config /local.yaml ``` Then it should just work to run otel-cli: ```shell ./otel-cli span -n "testing" -s "my first test span" # or for quick iterations: go run . span -n "testing" -s "my first test span" ``` ## Contributing Please file issues and PRs on the GitHub project at https://github.com/equinix-labs/otel-cli ## Releases Releases are managed by goreleaser. Currently this is limited to @tobert due to rules in the equinix-labs organization. For now releases are not automated, but will be by the time a v1.0 rolls out and the test suite is robust enough that we feel confident. Testing the release: `goreleaser release --snapshot --rm-dist` To release, a GitHub personal access token is required. The release also needs to be tagged in git. ```shell docker login ghcr.io # log into GitHub Docker repo gh repo list # make sure GitHub PAT is working git checkout main # release tags must be off the main branch git pull --rebase # get the latest HEAD git tag v0.1.1 # tag HEAD with the next version git push --tags # push new tag up to GitHub goreleaser release --rm-dist ``` ## License Apache 2.0, see LICENSE ================================================ FILE: TESTING.md ================================================ # Testing otel-cli ## Synopsis otel-cli's primary method of testing is functional, implemented in `main_test.go` and accompanying files. It sets up a server and runs an otel-cli build through a number of tests to verify that everything from environment variable passing to server TLS negotiation work as expected. ## Unit Testing Do it. It doesn't have to be fancy, just exercise the code a little. It's more about all of us being able to iterate quickly than reaching total coverage. Most unit tests are in the `otelcli` package. The tests in the root of this project are not unit tests. ## The otel-cli Test Harness When `go test` is run in the root of this project, it runs the available `./otel-cli` binary through a suite of tests, providing otel-cli with its endpoint information (via templates) and examining the payloads received on the server. The otel-cli test harness is more complex than otel-cli itself. Its goal is to be able to test that setting e.g. `OTEL_EXPORTER_OTLP_CLIENT_KEY` works all the way through to authenticating to a TLS server. The bugs are going to exist in the glue code, since that's mostly what otel-cli is. Each of Cobra, `encoding/json`, and opentelemetry-go are thorougly unit and battle tested. So, otel-cli tests a build in a functional test harness. Tests are defined in `data_for_test.go` in Go data structures. Suites are a group of Fixtures that go together. Mostly Suites are necessary for the backgrounding feature, to test e.g. `otel-cli span background`, and to organize tests by functionality, etc.. Fixtures configure everything for an otel-cli invocation, and what to expect back from it. The OTLP server functionality originally built for `otel-cli server tui` is re-used in the tests to run a server in a goroutine. It supports both gRPC and HTTP variants of OTLP, and can be configured with TLS. This allows otel-cli to connect to a server and send traces, which the harness then compares to expectations defined in the test Fixture. otel-cli has a special subcommand, `otel-cli status` that sends a span and reports back on otel-cli's internal state. The test harness detects status commands and can check data in it. `tls_for_test.go` implements an ephemeral certificate authority that is created and destroyed on each run. The rest of the test harness injects the CA and certs created into the tests, allowing for full-system testing. A Fixture can be configured to run "in the background". In this case, the harness will behave as if you ran the command `./otel-cli &` and let following fixtures run on top of it. This is mostly used to test `otel-cli span background`, which exists primarily to run as a backgrounded job in a shell script. When background jobs are in use, be careful with test names as they are used as a key to manage the process. ## Adding Tests For a new area of functionality, you'll want to add a Suite. A Suite is mostly for organization of tests, but is also used to manage state when testing background jobs. A Fixture is made of two parts: an otel-cli command configuration, and a data structure of expected results. The harness presents otel-cli with the exact ARGV specified in `Config.CliArgs`, and a clean environment with only the envvars provided in the `Env` stringmap. The output from otel-cli is captured with stdout and stderr combined. This can be tested against as well. It is often wise to pass `"--fail", "--verbose"` to CliArgs for debugging and it's fine to leave them on permanently. Without them otel-cli will be silent about failures and you'll get a confusing test output. Most of the time it's best to copy an existing Suite or Fixture and adjust it to the case you're trying to test. Please try to clean out any unneeded config when you do this so the tests are easy to understand. It's not bad to to test a little extra surface area, just try to keep things readable. ================================================ FILE: configs/otel-collector.yaml ================================================ --- receivers: otlp: protocols: grpc: exporters: logging: loglevel: debug jaeger: endpoint: "jaeger:14250" tls: insecure: true processors: batch: service: pipelines: traces: receivers: [otlp] processors: [batch] exporters: [jaeger,logging] ================================================ FILE: configs/otel-vendor-config.yaml ================================================ # opentelemetry-collector is a proxy for telemetry events. # # This configuration is set up for use in otel-cli development. # With collector in debug mode every trace is printed to the console # which makes working on otel-cli quick & easy. There are also # examples below for how to send to Lightstep and Honeycomb. receivers: otlp: protocols: # OTLP over gRPC grpc: endpoint: "0.0.0.0:4317" # OTLP over HTTP (opentelemetry-ruby only works on this proto for now) http: endpoint: "0.0.0.0:55681" processors: batch: exporters: # set to detailed and your traces will get printed to the console spammily debug: verbosity: detailed # Lightstep: set & export LIGHTSTEP_TOKEN and enable below otlp/1: endpoint: "ingest.lightstep.com:443" headers: "lightstep-access-token": "${LIGHTSTEP_TOKEN}" # Honeycomb: set & export HONEYCOMB_TEAM to the auth token # You may also want to set HONEYCOMB_DATASET for legacy accounts. otlp/2: endpoint: "api.honeycomb.io:443" headers: "x-honeycomb-team": "${HONEYCOMB_TEAM}" # Elastic: set & export the ELASTIC_TOKEN to the auth token for the APM server. otlp/3: endpoint: "xxx.elastic-cloud.com:443" headers: Authorization: "Bearer ${ELASTIC_TOKEN}" service: pipelines: traces: receivers: [otlp] processors: [batch] # only enable debug by default exporters: [debug] # Lightstep: # exporters: [debug, otlp/1] # Honeycomb: # exporters: [debug, otlp/2] # Elastic: # exporters: [debug, otlp/3] ================================================ FILE: data_for_test.go ================================================ package main_test // This file implements data structures and data for functional testing of // otel-cli. // // See: TESTING.md // // TODO: Results.SpanData could become a struct now import ( "os" "regexp" "syscall" "testing" "time" "github.com/equinix-labs/otel-cli/otelcli" "github.com/equinix-labs/otel-cli/otlpclient" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) type serverProtocol int const ( grpcProtocol serverProtocol = iota httpProtocol ) // CheckFunc is a function that gets called after the test is run to do // custom checking of values. type CheckFunc func(t *testing.T, fixture Fixture, results Results) type FixtureConfig struct { CliArgs []string Env map[string]string // timeout for how long to wait for the whole test in failure cases TestTimeoutMs int // when true this test will be excluded under go -test.short mode // TODO: maybe move this up to the suite? IsLongTest bool // either grpcProtocol or httpProtocol, defaults to grpc ServerProtocol serverProtocol // sets up the server with the test CA, requiring TLS ServerTLSEnabled bool // tells the server to require client certificate authentication ServerTLSAuthEnabled bool // for timeout tests we need to start the server to generate the endpoint // but do not want it to answer when otel-cli calls, this does that StopServerBeforeExec bool // run this fixture in the background, starting its server and otel-cli // instance, then let those block in the background and continue running // serial tests until it's "foreground" by a second fixtue with the same // description in the same file Background bool Foreground bool // for testing signal behavior a time to wait before sending a signal // and the signal to send can be specified KillAfter time.Duration KillSignal os.Signal } // mostly mirrors otelcli.StatusOutput but we need more type Results struct { // same as otelcli.StatusOutput but copied because embedding doesn't work for this Config otelcli.Config `json:"config"` SpanData map[string]string `json:"span_data"` Env map[string]string `json:"env"` Diagnostics otelcli.Diagnostics `json:"diagnostics"` Errors otlpclient.ErrorList `json:"errors"` // these are specific to tests... ServerMeta map[string]string Headers map[string]string // headers sent by the client ResourceSpans *tracepb.ResourceSpans CliOutput string // merged stdout and stderr CliOutputRe *regexp.Regexp // regular expression to clean the output before comparison SpanCount int // number of spans received EventCount int // number of events received TimedOut bool // true when test timed out CommandFailed bool // otel-cli was killed, did not exit() on its own ExitCode int // the process exit code returned by otel-cli Span *tracepb.Span SpanEvents []*tracepb.Span_Event } // Fixture represents a test fixture for otel-cli. type Fixture struct { Name string Config FixtureConfig Endpoint string TlsData TlsSettings Expect Results CheckFuncs []CheckFunc } // FixtureSuite is a list of Fixtures that run serially. type FixtureSuite []Fixture var suites = []FixtureSuite{ // otel-cli should not do anything when it is not explicitly configured" { { Name: "nothing configured", Config: FixtureConfig{ CliArgs: []string{"status"}, }, Expect: Results{ Config: otelcli.DefaultConfig(), Diagnostics: otelcli.Diagnostics{ IsRecording: false, NumArgs: 1, ParsedTimeoutMs: 1000, }, }, }, }, // setting minimum envvars should result in a span being received { { Name: "minimum configuration (recording, grpc)", Config: FixtureConfig{ ServerProtocol: grpcProtocol, CliArgs: []string{"status", "--endpoint", "{{endpoint}}"}, TestTimeoutMs: 1000, }, Expect: Results{ // otel-cli should NOT set insecure when it auto-detects localhost Config: otelcli.DefaultConfig(). WithEndpoint("{{endpoint}}"). WithInsecure(false), ServerMeta: map[string]string{ "proto": "grpc", }, Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 3, DetectedLocalhost: true, ParsedTimeoutMs: 1000, Endpoint: "*", EndpointSource: "*", }, SpanCount: 1, }, }, { Name: "minimum configuration (recording, http)", Config: FixtureConfig{ ServerProtocol: httpProtocol, CliArgs: []string{"status", "--endpoint", "http://{{endpoint}}"}, TestTimeoutMs: 1000, }, Expect: Results{ // otel-cli should NOT set insecure when it auto-detects localhost Config: otelcli.DefaultConfig(). WithEndpoint("http://{{endpoint}}"). WithInsecure(false), ServerMeta: map[string]string{ "content-type": "application/x-protobuf", "host": "{{endpoint}}", "method": "POST", "proto": "HTTP/1.1", "uri": "/v1/traces", }, Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 3, DetectedLocalhost: true, ParsedTimeoutMs: 1000, Endpoint: "*", EndpointSource: "*", }, SpanCount: 1, }, }, }, // everything works as expected with fully-loaded options { { Name: "fully loaded command line", Config: FixtureConfig{ ServerProtocol: grpcProtocol, TestTimeoutMs: 1000, Env: map[string]string{ "TRACEPARENT": "00-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-AAAAAAAAAAAAAAAA-01", }, CliArgs: []string{ "span", "--endpoint", "{{endpoint}}", "--protocol", "grpc", "--insecure", "--timeout", "500000us", // 500ms "--fail", "--verbose", "--name", "slartibartfast", "--service", "Starship Bistromath", "--kind", "server", "--start", "308534400", "--end", "1979-10-12T23:59:59Z", "--attrs", "protagonist=DentArthurdent,medium=book", "--otlp-headers", "lue=42", "--force-span-id", "BBBBBBBBBBBBBBBB", "--tp-required", "--tp-print", "--tp-export", }, }, Expect: Results{ SpanCount: 1, Config: otelcli.DefaultConfig(), Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 8, ParsedTimeoutMs: 1000, DetectedLocalhost: true, InsecureSkipVerify: true, }, SpanData: map[string]string{ "span_id": "*", "trace_id": "*", "attributes": `medium=book,protagonist=DentArthurdent`, }, Headers: map[string]string{ ":authority": "{{endpoint}}\n", "content-type": "application/grpc\n", "user-agent": "*", "lue": "42\n", }, CliOutput: "" + "# trace id: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n" + "# span id: bbbbbbbbbbbbbbbb\n" + "export TRACEPARENT=00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01\n", }, }, }, // TLS connections { { Name: "minimum configuration (tls, no-verify, recording, grpc)", Config: FixtureConfig{ ServerProtocol: grpcProtocol, CliArgs: []string{ "status", "--endpoint", "https://{{endpoint}}", "--protocol", "grpc", // TODO: switch to --tls-no-verify before 1.0, for now keep testing it "--verbose", "--fail", "--no-tls-verify", }, TestTimeoutMs: 1000, ServerTLSEnabled: true, }, Expect: Results{ Config: otelcli.DefaultConfig(). WithEndpoint("https://{{endpoint}}"). WithProtocol("grpc"). WithVerbose(true). WithTlsNoVerify(true), Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 8, DetectedLocalhost: true, InsecureSkipVerify: true, ParsedTimeoutMs: 1000, Endpoint: "*", EndpointSource: "*", }, SpanCount: 1, }, }, { Name: "minimum configuration (tls, no-verify, recording, https)", Config: FixtureConfig{ ServerProtocol: httpProtocol, CliArgs: []string{"status", "--endpoint", "https://{{endpoint}}", "--tls-no-verify"}, TestTimeoutMs: 2000, ServerTLSEnabled: true, }, Expect: Results{ // otel-cli should NOT set insecure when it auto-detects localhost Config: otelcli.DefaultConfig(). WithTlsNoVerify(true). WithEndpoint("https://{{endpoint}}"), Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 4, DetectedLocalhost: true, ParsedTimeoutMs: 1000, Endpoint: "*", EndpointSource: "*", }, SpanCount: 1, }, }, { Name: "minimum configuration (tls, client cert auth, recording, grpc)", Config: FixtureConfig{ ServerProtocol: grpcProtocol, CliArgs: []string{ "status", "--endpoint", "https://{{endpoint}}", "--protocol", "grpc", "--verbose", "--fail", "--tls-ca-cert", "{{tls_ca_cert}}", "--tls-client-cert", "{{tls_client_cert}}", "--tls-client-key", "{{tls_client_key}}", }, TestTimeoutMs: 1000, ServerTLSEnabled: true, ServerTLSAuthEnabled: true, }, Expect: Results{ Config: otelcli.DefaultConfig(). WithEndpoint("https://{{endpoint}}"). WithProtocol("grpc"). WithTlsCACert("{{tls_ca_cert}}"). WithTlsClientKey("{{tls_client_key}}"). WithTlsClientCert("{{tls_client_cert}}"). WithVerbose(true), Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 13, DetectedLocalhost: true, InsecureSkipVerify: true, ParsedTimeoutMs: 1000, Endpoint: "*", EndpointSource: "*", }, SpanCount: 1, }, }, { Name: "minimum configuration (tls, client cert auth, recording, https)", Config: FixtureConfig{ ServerProtocol: httpProtocol, CliArgs: []string{ "status", "--endpoint", "https://{{endpoint}}", "--verbose", "--fail", "--tls-ca-cert", "{{tls_ca_cert}}", "--tls-client-cert", "{{tls_client_cert}}", "--tls-client-key", "{{tls_client_key}}", }, TestTimeoutMs: 2000, ServerTLSEnabled: true, ServerTLSAuthEnabled: true, }, Expect: Results{ Config: otelcli.DefaultConfig(). WithEndpoint("https://{{endpoint}}"). WithTlsCACert("{{tls_ca_cert}}"). WithTlsClientKey("{{tls_client_key}}"). WithTlsClientCert("{{tls_client_cert}}"). WithVerbose(true), Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 11, DetectedLocalhost: true, ParsedTimeoutMs: 1000, Endpoint: "*", EndpointSource: "*", }, SpanCount: 1, }, }, }, // ensure things fail when they're supposed to fail { // otel is configured but there is no server listening so it should time out silently { Name: "timeout with no server", Config: FixtureConfig{ CliArgs: []string{"span", "--timeout", "1s"}, Env: map[string]string{ "OTEL_EXPORTER_OTLP_ENDPOINT": "{{endpoint}}", }, // this needs to be less than the timeout in CliArgs TestTimeoutMs: 500, IsLongTest: true, // can be skipped with `go test -short` StopServerBeforeExec: true, // there will be no server listening }, Expect: Results{ Config: otelcli.DefaultConfig(), // we want and expect a timeout and failure TimedOut: true, CommandFailed: true, }, }, { Name: "syntax errors in environment variables cause the command to fail", Config: FixtureConfig{ CliArgs: []string{"span", "--fail", "--verbose"}, Env: map[string]string{ "OTEL_EXPORTER_OTLP_ENDPOINT": "{{endpoint}}", "OTEL_CLI_VERBOSE": "lmao", // invalid input }, }, Expect: Results{ Config: otelcli.DefaultConfig(), ExitCode: 1, // strips the date off the log line before comparing to expectation CliOutputRe: regexp.MustCompile(`^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} `), CliOutput: "Error while loading environment variables: could not parse OTEL_CLI_VERBOSE value " + "\"lmao\" as an bool: strconv.ParseBool: parsing \"lmao\": invalid syntax\n", }, }, { Name: "https:// should fail when TLS is not available", Config: FixtureConfig{ ServerProtocol: httpProtocol, CliArgs: []string{"status", "--endpoint", "https://{{endpoint}}"}, TestTimeoutMs: 1000, }, Expect: Results{ Config: otelcli.DefaultConfig(). WithEndpoint("https://{{endpoint}}"), Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 3, DetectedLocalhost: true, ParsedTimeoutMs: 1000, Endpoint: "*", EndpointSource: "*", }, SpanCount: 0, }, CheckFuncs: []CheckFunc{ func(t *testing.T, f Fixture, r Results) { want := injectVars(`Post "https://{{endpoint}}/v1/traces": http: server gave HTTP response to HTTPS client`, f.Endpoint, f.TlsData) if len(r.Errors) >= 1 { if r.Errors[0].Error != want { t.Errorf("Got the wrong error: %q", r.Errors[0].Error) } } else { t.Errorf("Expected at least one error but got %d.", len(r.Errors)) } }, }, }, }, // regression tests { { // The span end time was missing when #175 merged, which showed up // as 0ms spans. CheckFuncs was added to make this possible. This // test runs sleep for 10ms and checks the duration of the span is // at least 10ms. Name: "#189 otel-cli exec sets span start time earlier than end time", Config: FixtureConfig{ // note: relies on system sleep command supporting floats // note: 10ms is hardcoded in a few places for this test and commentary CliArgs: []string{"exec", "--endpoint", "{{endpoint}}", "sleep", "0.01"}, }, Expect: Results{ SpanCount: 1, Config: otelcli.DefaultConfig().WithEndpoint("grpc://{{endpoint}}"), }, CheckFuncs: []CheckFunc{ func(t *testing.T, f Fixture, r Results) { //elapsed := r.Span.End.Sub(r.Span.Start) elapsed := time.Duration((r.Span.EndTimeUnixNano - r.Span.StartTimeUnixNano) * uint64(time.Nanosecond)) if elapsed.Milliseconds() < 10 { t.Errorf("elapsed test time not long enough. Expected 10ms, got %d ms", elapsed.Milliseconds()) } }, }, }, { Name: "#181 OTEL_ envvars should persist through to otel-cli exec", Config: FixtureConfig{ CliArgs: []string{"status"}, Env: map[string]string{ "OTEL_FAKE_VARIABLE": "fake value", "OTEL_EXPORTER_OTLP_ENDPOINT": "{{endpoint}}", "OTEL_EXPORTER_OTLP_CERTIFICATE": "{{tls_ca_cert}}", "X_WHATEVER": "whatever", }, }, Expect: Results{ SpanCount: 1, Config: otelcli.DefaultConfig().WithEndpoint("{{endpoint}}").WithTlsCACert("{{tls_ca_cert}}"), Env: map[string]string{ "OTEL_FAKE_VARIABLE": "fake value", "OTEL_EXPORTER_OTLP_ENDPOINT": "{{endpoint}}", "OTEL_EXPORTER_OTLP_CERTIFICATE": "{{tls_ca_cert}}", "X_WHATEVER": "whatever", }, Diagnostics: otelcli.Diagnostics{ IsRecording: true, DetectedLocalhost: true, NumArgs: 1, ParsedTimeoutMs: 1000, Endpoint: "*", EndpointSource: "*", }, }, }, { Name: "#200 custom trace path in general endpoint gets signal path appended", Config: FixtureConfig{ CliArgs: []string{"status", "--endpoint", "http://{{endpoint}}/mycollector"}, ServerProtocol: httpProtocol, }, Expect: Results{ SpanCount: 1, Config: otelcli.DefaultConfig().WithEndpoint("http://{{endpoint}}/mycollector"), Diagnostics: otelcli.Diagnostics{ IsRecording: true, DetectedLocalhost: true, NumArgs: 3, ParsedTimeoutMs: 1000, // spec says /v1/traces should get appended to any general endpoint URL Endpoint: "http://{{endpoint}}/mycollector/v1/traces", EndpointSource: "general", }, }, }, { Name: "#200 custom trace path on signal endpoint does not get modified", Config: FixtureConfig{ CliArgs: []string{"status", "--traces-endpoint", "http://{{endpoint}}/mycollector/x/1"}, ServerProtocol: httpProtocol, }, Expect: Results{ SpanCount: 1, Config: otelcli.DefaultConfig().WithTracesEndpoint("http://{{endpoint}}/mycollector/x/1"), Diagnostics: otelcli.Diagnostics{ IsRecording: true, DetectedLocalhost: true, NumArgs: 3, ParsedTimeoutMs: 1000, Endpoint: "http://{{endpoint}}/mycollector/x/1", EndpointSource: "signal", }, }, }, { Name: "#258 Commands that exit with a non-zero exit code should report a span", Config: FixtureConfig{ CliArgs: []string{"exec", "--endpoint", "{{endpoint}}", "--verbose", "--fail", "--", "false", }, }, Expect: Results{ ExitCode: 1, SpanCount: 1, CliOutput: "", CommandFailed: false, // otel-cli should exit voluntarily in this case Config: otelcli.DefaultConfig().WithEndpoint("grpc://{{endpoint}}"), }, }, { Name: "#316 ensure process command and args are sent as attributes", Config: FixtureConfig{ CliArgs: []string{"exec", "--endpoint", "{{endpoint}}", "--verbose", "--fail", "--attrs", "zy=ab", // ensure CLI args still propagate "--", "/bin/echo", "a", "z", }, }, Expect: Results{ SpanCount: 1, CliOutput: "a z\n", SpanData: map[string]string{ "attributes": "/^process.command=/bin/echo,process.command_args=/bin/echo,a,z,process.owner=\\w+,process.parent_pid=\\d+,process.pid=\\d+,zy=ab/", }, }, }, }, // otel-cli span with no OTLP config should do and print nothing { { Name: "otel-cli span (unconfigured, non-recording)", Config: FixtureConfig{ CliArgs: []string{"span", "--service", "main_test.go", "--name", "test-span-123", "--kind", "server"}, }, Expect: Results{Config: otelcli.DefaultConfig()}, }, }, // config file { { Name: "load a json config file", Config: FixtureConfig{ CliArgs: []string{"status", "--config", "example-config.json"}, // this will take priority over the config Env: map[string]string{ "OTEL_EXPORTER_OTLP_ENDPOINT": "{{endpoint}}", }, TestTimeoutMs: 1000, }, Expect: Results{ SpanCount: 1, Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 3, ParsedTimeoutMs: 1000, Endpoint: "*", EndpointSource: "*", DetectedLocalhost: true, Error: "could not open file '/tmp/traceparent.txt' for read: open /tmp/traceparent.txt: no such file or directory", }, Env: map[string]string{ "OTEL_EXPORTER_OTLP_ENDPOINT": "{{endpoint}}", }, Config: otelcli.DefaultConfig(). WithEndpoint("{{endpoint}}"). // tells the test framework to ignore/overwrite WithTimeout("1s"). WithHeaders(map[string]string{"header1": "header1-value"}). WithInsecure(true). WithBlocking(false). WithTlsNoVerify(true). WithTlsCACert("/dev/null"). WithTlsClientKey("/dev/null"). WithTlsClientCert("/dev/null"). WithServiceName("configured_in_config_file"). WithSpanName("config_file_span"). WithKind("server"). WithAttributes(map[string]string{"attr1": "value1"}). WithStatusCode("0"). WithStatusDescription("status description"). WithTraceparentCarrierFile("/tmp/traceparent.txt"). WithTraceparentIgnoreEnv(true). WithTraceparentPrint(true). WithTraceparentPrintExport(true). WithTraceparentRequired(false). WithBackgroundParentPollMs(100). WithBackgroundSockdir("/tmp"). WithBackgroundWait(true). WithSpanEndTime("now"). WithSpanEndTime("now"). WithEventName("config_file_event"). WithEventTime("now"). WithCfgFile("example-config.json"). WithVerbose(true). WithFail(true), }, }, }, // otel-cli with minimal config span sends a span that looks right { { Name: "otel-cli span (recording)", Config: FixtureConfig{ CliArgs: []string{"span", "--service", "main_test.go", "--name", "test-span-123", "--kind", "server"}, Env: map[string]string{ "OTEL_EXPORTER_OTLP_ENDPOINT": "{{endpoint}}", }, TestTimeoutMs: 1000, }, Expect: Results{ Config: otelcli.DefaultConfig(), SpanCount: 1, }, }, // OTEL_RESOURCE_ATTRIBUTES and OTEL_CLI_SERVICE_NAME should get merged into // the span resource attributes { Name: "otel-cli span with envvar service name and attributes (recording)", Config: FixtureConfig{ CliArgs: []string{"span", "--name", "test-span-service-name-and-attrs", "--kind", "server"}, Env: map[string]string{ "OTEL_EXPORTER_OTLP_ENDPOINT": "{{endpoint}}", "OTEL_CLI_SERVICE_NAME": "test-service-abc123", "OTEL_CLI_ATTRIBUTES": "cafe=deadbeef,abc=123", "OTEL_RESOURCE_ATTRIBUTES": "foo.bar=baz", }, TestTimeoutMs: 1000, }, Expect: Results{ Config: otelcli.DefaultConfig(), SpanData: map[string]string{ "span_id": "*", "trace_id": "*", "attributes": "abc=123,cafe=deadbeef", "service_attributes": "foo.bar=baz,service.name=test-service-abc123", }, SpanCount: 1, }, }, // OTEL_SERVICE_NAME { Name: "otel-cli span with envvar service name (recording)", Config: FixtureConfig{ CliArgs: []string{"span"}, Env: map[string]string{ "OTEL_EXPORTER_OTLP_ENDPOINT": "{{endpoint}}", "OTEL_SERVICE_NAME": "test-service-123abc", }, TestTimeoutMs: 1000, }, Expect: Results{ Config: otelcli.DefaultConfig(), SpanData: map[string]string{ "service_attributes": "service.name=test-service-123abc", }, SpanCount: 1, }, }, }, // otel-cli span --print-tp actually prints { { Name: "otel-cli span --print-tp (non-recording)", Config: FixtureConfig{ CliArgs: []string{"span", "--tp-print"}, Env: map[string]string{"TRACEPARENT": "00-f6c109f48195b451c4def6ab32f47b61-a5d2a35f2483004e-01"}, }, Expect: Results{ Config: otelcli.DefaultConfig(), CliOutput: "" + // empty so the text below can indent and line up "# trace id: f6c109f48195b451c4def6ab32f47b61\n" + "# span id: a5d2a35f2483004e\n" + "TRACEPARENT=00-f6c109f48195b451c4def6ab32f47b61-a5d2a35f2483004e-01\n", }, }, }, // otel-cli span --print-tp propagates traceparent even when not recording { { Name: "otel-cli span --tp-print --tp-export (non-recording)", Config: FixtureConfig{ CliArgs: []string{"span", "--tp-print", "--tp-export"}, Env: map[string]string{ "TRACEPARENT": "00-f6c109f48195b451c4def6ab32f47b61-a5d2a35f2483004e-00", }, }, Expect: Results{ Config: otelcli.DefaultConfig(), CliOutput: "" + "# trace id: f6c109f48195b451c4def6ab32f47b61\n" + "# span id: a5d2a35f2483004e\n" + "export TRACEPARENT=00-f6c109f48195b451c4def6ab32f47b61-a5d2a35f2483004e-00\n", }, }, }, // otel-cli span background, non-recording, this uses the suite functionality // and background tasks, which are a little clunky but get the job done { { Name: "otel-cli span background (nonrecording)", Config: FixtureConfig{ CliArgs: []string{"span", "background", "--timeout", "1s", "--sockdir", "."}, TestTimeoutMs: 2000, Background: true, // sorta like & in shell Foreground: false, // must be true later, like `fg` in shell }, Expect: Results{Config: otelcli.DefaultConfig()}, }, { Name: "otel-cli span event", Config: FixtureConfig{ CliArgs: []string{"span", "event", "--name", "an event happened", "--attrs", "ima=now,mondai=problem", "--sockdir", "."}, }, Expect: Results{Config: otelcli.DefaultConfig()}, }, { Name: "otel-cli span end", Config: FixtureConfig{ CliArgs: []string{"span", "end", "--sockdir", "."}, }, Expect: Results{Config: otelcli.DefaultConfig()}, }, { // Name on foreground *must* match the backgrounded job // TODO: ^^ this isn't great, find a better way Name: "otel-cli span background (nonrecording)", Config: FixtureConfig{ Foreground: true, // bring it back (fg) and finish up }, Expect: Results{Config: otelcli.DefaultConfig()}, }, }, // otel-cli span background, in recording mode { { Name: "otel-cli span background (recording)", Config: FixtureConfig{ CliArgs: []string{"span", "background", "--timeout", "1s", "--sockdir", ".", "--attrs", "abc=def"}, Env: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "{{endpoint}}"}, TestTimeoutMs: 2000, Background: true, Foreground: false, }, Expect: Results{ Config: otelcli.DefaultConfig(), SpanData: map[string]string{ "span_id": "*", "trace_id": "*", "attributes": `abc=def`, // weird format because of limitation in OTLP server }, SpanCount: 1, EventCount: 1, }, // this validates options sent to otel-cli span end CheckFuncs: []CheckFunc{ func(t *testing.T, f Fixture, r Results) { if r.Span.Status.GetCode() != 2 { t.Errorf("expected 2 for span status code, but got %d", r.Span.Status.GetCode()) } if r.Span.Status.GetMessage() != "I can't do that Dave." { t.Errorf("got wrong string for status description: %q", r.Span.Status.GetMessage()) } }, }, }, { Name: "otel-cli span event", Config: FixtureConfig{ CliArgs: []string{"span", "event", "--name", "an event happened", "--attrs", "ima=now,mondai=problem", "--sockdir", "."}, }, Expect: Results{Config: otelcli.DefaultConfig()}, }, { Name: "otel-cli span end", Config: FixtureConfig{ CliArgs: []string{ "span", "end", "--sockdir", ".", // these are validated by checkfuncs defined above ^^ "--status-code", "error", "--status-description", "I can't do that Dave.", }, }, Expect: Results{Config: otelcli.DefaultConfig()}, }, { Name: "otel-cli span background (recording)", Config: FixtureConfig{ Foreground: true, // fg }, Expect: Results{Config: otelcli.DefaultConfig()}, }, }, // otel-cli span background, add attrs on span end { { Name: "otel-cli span background (recording) with attrs added on end", Config: FixtureConfig{ CliArgs: []string{"span", "background", "--timeout", "1s", "--sockdir", "."}, Env: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "{{endpoint}}"}, TestTimeoutMs: 2000, Background: true, Foreground: false, }, Expect: Results{ Config: otelcli.DefaultConfig(), SpanData: map[string]string{ "span_id": "*", "trace_id": "*", "attributes": `abc=def,ghi=jkl`, // weird format because of limitation in OTLP server }, SpanCount: 1, }, }, { Name: "otel-cli span end", Config: FixtureConfig{ CliArgs: []string{ "span", "end", "--sockdir", ".", "--attrs", "ghi=jkl,abc=def", }, }, Expect: Results{Config: otelcli.DefaultConfig()}, }, { Name: "otel-cli span background (recording) with attrs added on end", Config: FixtureConfig{ Foreground: true, // fg }, Expect: Results{Config: otelcli.DefaultConfig()}, }, }, // otel-cli span background with attrs, append attrs on span end { { Name: "otel-cli span background (recording) with attrs append on end", Config: FixtureConfig{ CliArgs: []string{"span", "background", "--timeout", "1s", "--sockdir", ".", "--attrs", "abc=def"}, Env: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "{{endpoint}}"}, TestTimeoutMs: 2000, Background: true, Foreground: false, }, Expect: Results{ Config: otelcli.DefaultConfig(), SpanData: map[string]string{ "span_id": "*", "trace_id": "*", "attributes": `abc=def,ghi=jkl`, // weird format because of limitation in OTLP server }, SpanCount: 1, }, }, { Name: "otel-cli span end", Config: FixtureConfig{ CliArgs: []string{ "span", "end", "--sockdir", ".", "--attrs", "ghi=jkl", }, }, Expect: Results{Config: otelcli.DefaultConfig()}, }, { Name: "otel-cli span background (recording) with attrs append on end", Config: FixtureConfig{ Foreground: true, // fg }, Expect: Results{Config: otelcli.DefaultConfig()}, }, }, // otel-cli span background, modify and add attrs on span end { { Name: "otel-cli span background (recording) with attrs modified and added on end", Config: FixtureConfig{ CliArgs: []string{"span", "background", "--timeout", "1s", "--sockdir", ".", "--attrs", "abc=123"}, Env: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "{{endpoint}}"}, TestTimeoutMs: 2000, Background: true, Foreground: false, }, Expect: Results{ Config: otelcli.DefaultConfig(), SpanData: map[string]string{ "span_id": "*", "trace_id": "*", "attributes": `abc=def,ghi=jkl`, // weird format because of limitation in OTLP server }, SpanCount: 1, }, }, { Name: "otel-cli span end", Config: FixtureConfig{ CliArgs: []string{ "span", "end", "--sockdir", ".", "--attrs", "ghi=jkl,abc=def", }, }, Expect: Results{Config: otelcli.DefaultConfig()}, }, { Name: "otel-cli span background (recording) with attrs modified and added on end", Config: FixtureConfig{ Foreground: true, // fg }, Expect: Results{Config: otelcli.DefaultConfig()}, }, }, // otel-cli exec runs echo { { Name: "otel-cli span exec echo", Config: FixtureConfig{ // intentionally calling a command with no args bc it's a special case in exec.go CliArgs: []string{"exec", "--service", "main_test.go", "--name", "test-span-123", "--kind", "server", "echo"}, Env: map[string]string{ "OTEL_EXPORTER_OTLP_ENDPOINT": "{{endpoint}}", "TRACEPARENT": "00-edededededededededededededed9000-edededededededed-01", }, }, Expect: Results{ Config: otelcli.DefaultConfig(), SpanData: map[string]string{ "span_id": "*", "trace_id": "edededededededededededededed9000", }, CliOutput: "\n", SpanCount: 1, }, }, }, // otel-cli exec runs otel-cli exec { { Name: "otel-cli exec (nested)", Config: FixtureConfig{ CliArgs: []string{ "exec", "--name", "outer", "--endpoint", "{{endpoint}}", "--fail", "--verbose", "--", "./otel-cli", "exec", "--name", "inner", "--endpoint", "{{endpoint}}", "--tp-required", "--fail", "--verbose", "echo", "hello world"}, }, Expect: Results{ Config: otelcli.DefaultConfig(), CliOutput: "hello world\n", SpanCount: 2, }, }, }, // otel-cli exec echo "{{traceparent}}" and otel-cli exec --tp-disable-inject { { Name: "otel-cli exec with arg injection injects the traceparent", Config: FixtureConfig{ CliArgs: []string{ "exec", "--endpoint", "{{endpoint}}", "--force-trace-id", "e39280f2980af3a8600ae98c74f2dabf", "--force-span-id", "023eee2731392b4d", "--", "echo", "{{traceparent}}"}, }, Expect: Results{ Config: otelcli.DefaultConfig().WithEndpoint("{{endpoint}}"), CliOutput: "00-e39280f2980af3a8600ae98c74f2dabf-023eee2731392b4d-01\n", SpanCount: 1, }, }, { Name: "otel-cli exec --tp-disable-inject returns the {{traceparent}} tag unmodified", Config: FixtureConfig{ CliArgs: []string{ "exec", "--endpoint", "{{endpoint}}", "--force-trace-id", "e39280f2980af3a8600ae98c74f2dabf", "--force-span-id", "023eee2731392b4d", "--tp-disable-inject", "--", "echo", "{{traceparent}}"}, }, Expect: Results{ Config: otelcli.DefaultConfig().WithEndpoint("{{endpoint}}"), CliOutput: "{{traceparent}}\n", SpanCount: 1, }, }, { Name: "otel-cli exec returns the {{traceparent}} tag unmodified with OTEL_CLI_EXEC_TP_DISABLE_INJECT", Config: FixtureConfig{ Env: map[string]string{ "OTEL_CLI_EXEC_TP_DISABLE_INJECT": "true", }, CliArgs: []string{ "exec", "--endpoint", "{{endpoint}}", "--force-trace-id", "e39280f2980af3a8600ae98c74f2dabf", "--force-span-id", "023eee2731392b4d", "--", "echo", "{{traceparent}}"}, }, Expect: Results{ Config: otelcli.DefaultConfig().WithEndpoint("{{endpoint}}"), CliOutput: "{{traceparent}}\n", SpanCount: 1, }, }, }, // validate OTEL_EXPORTER_OTLP_PROTOCOL / --protocol { // --protocol { Name: "--protocol grpc", Config: FixtureConfig{ ServerProtocol: grpcProtocol, CliArgs: []string{"status", "--endpoint", "{{endpoint}}", "--protocol", "grpc"}, TestTimeoutMs: 1000, }, Expect: Results{ Config: otelcli.DefaultConfig().WithEndpoint("{{endpoint}}").WithProtocol("grpc"), ServerMeta: map[string]string{ "proto": "grpc", }, Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 5, DetectedLocalhost: true, ParsedTimeoutMs: 1000, Endpoint: "*", EndpointSource: "*", }, SpanCount: 1, }, }, { Name: "--protocol http/protobuf", Config: FixtureConfig{ ServerProtocol: httpProtocol, CliArgs: []string{"status", "--endpoint", "http://{{endpoint}}", "--protocol", "http/protobuf"}, TestTimeoutMs: 1000, }, Expect: Results{ Config: otelcli.DefaultConfig().WithEndpoint("http://{{endpoint}}").WithProtocol("http/protobuf"), ServerMeta: map[string]string{ "content-type": "application/x-protobuf", "host": "{{endpoint}}", "method": "POST", "proto": "HTTP/1.1", "uri": "/v1/traces", }, Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 5, DetectedLocalhost: true, ParsedTimeoutMs: 1000, Endpoint: "*", EndpointSource: "*", }, SpanCount: 1, }, }, { Name: "protocol: bad config", Config: FixtureConfig{ CliArgs: []string{"status", "--endpoint", "{{endpoint}}", "--protocol", "xxx", "--verbose", "--fail"}, TestTimeoutMs: 1000, }, Expect: Results{ CliOutputRe: regexp.MustCompile(`^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} `), CliOutput: "invalid protocol setting \"xxx\"\n", Config: otelcli.DefaultConfig().WithEndpoint("{{endpoint}}"), Diagnostics: otelcli.Diagnostics{ IsRecording: false, NumArgs: 7, DetectedLocalhost: true, ParsedTimeoutMs: 1000, Endpoint: "*", EndpointSource: "*", ExecExitCode: 1, }, SpanCount: 0, }, }, // OTEL_EXPORTER_OTLP_PROTOCOL { Name: "OTEL_EXPORTER_OTLP_PROTOCOL grpc", Config: FixtureConfig{ ServerProtocol: grpcProtocol, // validate protocol can be set to grpc with an http endpoint CliArgs: []string{"status", "--endpoint", "http://{{endpoint}}"}, TestTimeoutMs: 1000, Env: map[string]string{ "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc", }, }, Expect: Results{ Config: otelcli.DefaultConfig().WithEndpoint("http://{{endpoint}}").WithProtocol("grpc"), ServerMeta: map[string]string{ "proto": "grpc", }, Env: map[string]string{ "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc", }, Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 3, DetectedLocalhost: true, ParsedTimeoutMs: 1000, Endpoint: "*", EndpointSource: "*", }, SpanCount: 1, }, }, { Name: "OTEL_EXPORTER_OTLP_PROTOCOL http/protobuf", Config: FixtureConfig{ ServerProtocol: httpProtocol, CliArgs: []string{"status", "--endpoint", "http://{{endpoint}}"}, TestTimeoutMs: 1000, Env: map[string]string{ "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf", }, }, Expect: Results{ Config: otelcli.DefaultConfig().WithEndpoint("http://{{endpoint}}").WithProtocol("http/protobuf"), ServerMeta: map[string]string{ "content-type": "application/x-protobuf", "host": "{{endpoint}}", "method": "POST", "proto": "HTTP/1.1", "uri": "/v1/traces", }, Env: map[string]string{ "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf", }, Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 3, DetectedLocalhost: true, ParsedTimeoutMs: 1000, Endpoint: "*", EndpointSource: "*", }, SpanCount: 1, }, }, { Name: "OTEL_EXPORTER_OTLP_PROTOCOL: bad config", Config: FixtureConfig{ CliArgs: []string{"status", "--endpoint", "http://{{endpoint}}", "--fail", "--verbose"}, TestTimeoutMs: 1000, Env: map[string]string{ "OTEL_EXPORTER_OTLP_PROTOCOL": "roflcopter", }, }, Expect: Results{ ExitCode: 1, CliOutputRe: regexp.MustCompile(`^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} `), CliOutput: "invalid protocol setting \"roflcopter\"\n", Config: otelcli.DefaultConfig().WithEndpoint("http://{{endpoint}}"), Diagnostics: otelcli.Diagnostics{ IsRecording: false, NumArgs: 3, DetectedLocalhost: true, ParsedTimeoutMs: 1000, Endpoint: "*", EndpointSource: "*", Error: "invalid protocol setting \"roflcopter\"\n", }, SpanCount: 0, }, }, }, // --force-trace-id, --force-span-id and --force-parent-span-id allow setting/forcing custom trace, span and parent span ids { { Name: "forced trace, span and parent span ids", Config: FixtureConfig{ CliArgs: []string{ "status", "--endpoint", "{{endpoint}}", "--fail", "--force-trace-id", "00112233445566778899aabbccddeeff", "--force-span-id", "beefcafefacedead", "--force-parent-span-id", "e4e3eeb33fc4f3d3", }, }, Expect: Results{ Config: otelcli.DefaultConfig().WithEndpoint("{{endpoint}}"), SpanData: map[string]string{ "trace_id": "00112233445566778899aabbccddeeff", "span_id": "beefcafefacedead", "parent_span_id": "e4e3eeb33fc4f3d3", }, SpanCount: 1, Diagnostics: otelcli.Diagnostics{ NumArgs: 10, IsRecording: true, DetectedLocalhost: true, ParsedTimeoutMs: 1000, Endpoint: "*", EndpointSource: "*", }, }, }, }, // full-system test --otlp-headers makes it to grpc/http servers { { Name: "#231 gRPC headers for authentication", Config: FixtureConfig{ CliArgs: []string{ "status", "--endpoint", "{{endpoint}}", "--protocol", "grpc", "--otlp-headers", "x-otel-cli-otlpserver-token=abcdefgabcdefg", }, ServerProtocol: grpcProtocol, }, Expect: Results{ SpanCount: 1, Config: otelcli.DefaultConfig(). WithEndpoint("{{endpoint}}"). WithProtocol("grpc"). WithHeaders(map[string]string{ "x-otel-cli-otlpserver-token": "abcdefgabcdefg", }), Headers: map[string]string{ ":authority": "{{endpoint}}\n", "content-type": "application/grpc\n", "user-agent": "*", "x-otel-cli-otlpserver-token": "abcdefgabcdefg\n", }, Diagnostics: otelcli.Diagnostics{ IsRecording: true, DetectedLocalhost: true, NumArgs: 7, ParsedTimeoutMs: 1000, Endpoint: "grpc://{{endpoint}}", EndpointSource: "general", }, }, }, { Name: "#231 http headers for authentication", Config: FixtureConfig{ CliArgs: []string{ "status", "--endpoint", "http://{{endpoint}}", "--protocol", "http/protobuf", "--otlp-headers", "x-otel-cli-otlpserver-token=abcdefgabcdefg", }, ServerProtocol: httpProtocol, }, Expect: Results{ SpanCount: 1, Config: otelcli.DefaultConfig(). WithEndpoint("http://{{endpoint}}"). WithProtocol("http/protobuf"). WithHeaders(map[string]string{ "x-otel-cli-otlpserver-token": "abcdefgabcdefg", }), Headers: map[string]string{ "Content-Type": "application/x-protobuf", "Accept-Encoding": "gzip", "User-Agent": "Go-http-client/1.1", "X-Otel-Cli-Otlpserver-Token": "abcdefgabcdefg", }, Diagnostics: otelcli.Diagnostics{ IsRecording: true, DetectedLocalhost: true, NumArgs: 7, ParsedTimeoutMs: 1000, Endpoint: "http://{{endpoint}}/v1/traces", EndpointSource: "general", }, }, }, }, // exec signal and timeout behavior { { Name: "handle ctrl-c gracefully", Config: FixtureConfig{ CliArgs: []string{"exec", "--endpoint", "{{endpoint}}", "--timeout", "2s", "sleep", "1"}, KillAfter: time.Millisecond * 20, KillSignal: syscall.SIGINT, // control-c TestTimeoutMs: 50, // if we get to 50ms the signal failed }, Expect: Results{ SpanCount: 1, Config: otelcli.DefaultConfig().WithEndpoint("{{endpoint}}"), ExitCode: 2, SpanData: map[string]string{ "status_code": "2", "status_description": "exec command failed: signal: interrupt", }, }, }, { Name: "exec --command-timeout terminates processes", Config: FixtureConfig{ CliArgs: []string{"exec", "--endpoint", "{{endpoint}}", "--command-timeout", "20ms", "sleep", "1", }, TestTimeoutMs: 50, }, Expect: Results{ SpanCount: 1, Config: otelcli.DefaultConfig().WithEndpoint("{{endpoint}}"), ExitCode: 2, SpanData: map[string]string{ "status_code": "2", "status_description": "exec command failed: signal: killed", }, }, }, { Name: "exec --command-timeout can run longer than --timeout", Config: FixtureConfig{ CliArgs: []string{"exec", "--endpoint", "{{endpoint}}", "--command-timeout", "100ms", "--timeout", "50ms", "sleep", "0.75", // depends on GNU sleep's floating point sleeps }, TestTimeoutMs: 200, }, Expect: Results{ SpanCount: 1, Config: otelcli.DefaultConfig().WithEndpoint("{{endpoint}}"), ExitCode: 0, }, }, }, } ================================================ FILE: demos/01-simple-span.sh ================================================ #!/bin/bash # an otel-cli demo # this isn't super precise because of process timing but good enough # in many cases to be useful st=$(date +%s.%N) # unix epoch time with nanoseconds data1=$(uuidgen) data2=$(uuidgen) et=$(date +%s.%N) # don't worry, there are also short options :) ../otel-cli span \ --service "otel-cli-demo" \ --name "hello world" \ --kind "client" \ --start $st \ --end $et \ --tp-print \ --attrs "my.data1=$data1,my.data2=$data2" ================================================ FILE: demos/05-nested-exec.sh ================================================ #!/bin/bash # an otel-cli demo of nested exec # # this isn't necessarily practical, but it demonstrates how the TRACEPARENT # environment variable carries the context from invocation to invocation # so that the tracing provider (e.g. Honeycomb) can put it all back together # set the service name automatically on calls to otel-cli export OTEL_SERVICE_NAME="otel-cli-demo" # generate a new trace & span, cli will print out the 'export TRACEPARENT' carrier=$(mktemp) ../otel-cli span -n "traceparent demo $0" --tp-print --tp-carrier $carrier # this will start a child span, and run another otel-cli as its program ../otel-cli exec \ --name "hammer the server for sweet sweet data" \ --kind "client" \ --tp-carrier $carrier \ --verbose \ --fail \ -- \ ../otel-cli exec -n fake-server -k server /bin/echo 500 NOPE # ^ child span, the responding "server" that just echos NOPE ================================================ FILE: demos/10-span-background-simple.sh ================================================ #!/bin/bash # an otel-cli demo of span background ../otel-cli span background \ --service otel-cli-demo \ --name "executing $0" \ --timeout 2 & # ^ run otel-cli in the background sleep 1 # that's it, that's the demo # when this script exits, otel-cli will exit too so total runtime will # be a bit over 1 second ================================================ FILE: demos/15span-background-layered.sh ================================================ #!/bin/bash # an otel-cli demo of span background # # This demo shows span background functionality with events added to the span # while it's running in the background, then a child span is created and # the background span is ended gracefully. set -e set -x carrier=$(mktemp) # traceparent propagation via tempfile sockdir=$(mktemp -d) # a unix socket will be created here export OTEL_SERVICE_NAME="otel-cli-demo" # start the span background server, set up trace propagation, and # time out after 10 seconds (which shouldn't be reached) ../otel-cli span background \ --verbose --fail \ --tp-carrier $carrier \ --sockdir $sockdir \ --tp-print \ --name "$0 script execution" \ --timeout 10 & data1=$(uuidgen) # add an event to the span running in the background, with an attribute # set to the uuid we just generated ../otel-cli span event \ --verbose \ --name "did a thing" \ --sockdir $sockdir \ --attrs "data1=$data1" # waste some time sleep 1 # add an event that says we wasted some time ../otel-cli span event --verbose --name "slept 1 second" --sockdir $sockdir # run a shorter sleep inside a child span, also note that this is using # --tp-required so this will fail loudly if there is no traceparent # available ../otel-cli exec \ --verbose --fail \ --name "sleep 0.2" \ --tp-required \ --tp-carrier $carrier \ --tp-print \ sleep 0.2 # finally, tell the background server we're all done and it can exit ../otel-cli span end --sockdir $sockdir ================================================ FILE: demos/20span-background-race-workarounds.sh ================================================ #!/bin/bash # an otel-cli demo of workarounds for race conditions on span background # # otel-cli span background is usually run as a subprocess with & in the command # as below. An issue that shows up sometimes is a race condition where the shell # starts otel-cli in the background, and then immediately calls otel-cli span # or similar hoping to use the --tp-carrier file, which might not be created # before the process looks for it. There are a couple solutions below. set -e set -x carrier=$(mktemp) # traceparent propagation via tempfile sockdir=$(mktemp -d) # a unix socket will be created here export OTEL_SERVICE_NAME="otel-cli-demo" ../otel-cli span background \ --tp-carrier $carrier \ --sockdir $sockdir \ --service otel-cli \ --name "$0 script execution #1" \ --timeout 10 & # On Linux, the inotifywait command will do the trick, waiting for the # file to be written. Without a timeout it could hang forever if it loses # the race and otel-cli finishes writing the carrier before inotifywait # starts watching. A short timeout ensures it won't hang. [ ! -e $carrier ] && inotifywait --timeout 0.1 $carrier ../otel-cli span --tp-carrier $carrier --name "child of span background, after inotifywait" ../otel-cli span end --sockdir $sockdir # start another one for the second example ../otel-cli span background \ --tp-carrier $carrier \ --sockdir $sockdir \ --service otel-cli \ --name "$0 script execution #2" \ --timeout 10 & # otel-cli span event already waits for span background's socket file # to appear so just sending an event will do enough synchronization, at # the cost of a meaningless event. ../otel-cli span event --sockdir $sockdir --name "wait for span background" ../otel-cli span --tp-carrier $carrier --name "child of span background, after span event" ../otel-cli span end --sockdir $sockdir ================================================ FILE: demos/25srecon22-talk-agenda.sh ================================================ #!/bin/bash # a quick script to render a talk agenda using OTel svc="SRECon" # turns out the date doesn't matter much for this exercise since # we don't show it # but it is important for /finding/ these spans once you've sent them # so use today's date and hour but munge the rest talk_start=$(date +%Y-%m-%dT%H:00:00.00000Z) talk_end=$(date +%Y-%m-%dT%H:00:40.00000Z) tpfile=$(mktemp) otel-cli span \ --name "OpenTelemetry::Trace.tracer(BareMetal)" \ --service "$svc" \ --start "$talk_start" \ --end "$talk_end" \ --tp-print \ --tp-export \ --tp-carrier $tpfile # set the traceparent . $tpfile otel-cli span \ --name "Agenda" \ --service "$svc" \ --start "$(echo "$talk_start" |sed "s/:00:00/:01:00/")" \ --end "$(echo "$talk_start" |sed "s/:00:00/:02:00/")" subtpfile=$(mktemp) otel-cli span \ --name "The Bad Old Days" \ --service "$svc" \ --start "$(echo "$talk_start" |sed "s/:00:00/:02:01/")" \ --end "$(echo "$talk_start" |sed "s/:00:00/:03:01/")" subtpfile=$(mktemp) otel-cli span \ --name "Tracing All the Things!" \ --service "$svc" \ --start "$(echo "$talk_start" |sed "s/:00:00/:03:01/")" \ --end "$(echo "$talk_start" |sed "s/:00:00/:10:01/")" \ --tp-export \ --tp-carrier $subtpfile . $subtpfile # next spans are under ^^ otel-cli span \ --name "OTel in the Metal API" \ --service "$svc" \ --start "$(echo "$talk_start" |sed "s/:00:00/:03:01/")" \ --end "$(echo "$talk_start" |sed "s/:00:00/:04:00/")" otel-cli span \ --name "Fits and Starts" \ --service "$svc" \ --start "$(echo "$talk_start" |sed "s/:00:00/:04:01/")" \ --end "$(echo "$talk_start" |sed "s/:00:00/:07:00/")" otel-cli span \ --name "Introducing the Metal SRE team" \ --service "$svc" \ --start "$(echo "$talk_start" |sed "s/:00:00/:07:01/")" \ --end "$(echo "$talk_start" |sed "s/:00:00/:10:00/")" . $tpfile # back to top trace subtpfile=$(mktemp) otel-cli span \ --name "Observability Onboarding" \ --service "$svc" \ --start "$(echo "$talk_start" |sed "s/:00:00/:10:01/")" \ --end "$(echo "$talk_start" |sed "s/:00:00/:15:01/")" \ --tp-export \ --tp-carrier $subtpfile . $subtpfile # next spans are under ^^ otel-cli span \ --name "Order of Operations" \ --service "$svc" \ --start "$(echo "$talk_start" |sed "s/:00:00/:10:01/")" \ --end "$(echo "$talk_start" |sed "s/:00:00/:12:00/")" otel-cli span \ --name "Mental Models" \ --service "$svc" \ --start "$(echo "$talk_start" |sed "s/:00:00/:12:01/")" \ --end "$(echo "$talk_start" |sed "s/:00:00/:14:00/")" otel-cli span \ --name "How We Know We're on the Right Track" \ --service "$svc" \ --start "$(echo "$talk_start" |sed "s/:00:00/:14:01/")" \ --end "$(echo "$talk_start" |sed "s/:00:00/:16:00/")" . $tpfile # back to top trace subtpfile=$(mktemp) otel-cli span \ --name "Tracing Wins" \ --service "$svc" \ --start "$(echo "$talk_start" |sed "s/:00:00/:16:01/")" \ --end "$(echo "$talk_start" |sed "s/:00:00/:22:01/")" \ --tp-export \ --tp-carrier $subtpfile . $subtpfile # next spans are under ^^ otel-cli span \ --name "Performance Project" \ --service "$svc" \ --start "$(echo "$talk_start" |sed "s/:00:00/:16:02/")" \ --end "$(echo "$talk_start" |sed "s/:00:00/:19:00/")" otel-cli span \ --name "Sociotechnical Wins" \ --service "$svc" \ --start "$(echo "$talk_start" |sed "s/:00:00/:19:01/")" \ --end "$(echo "$talk_start" |sed "s/:00:00/:22:00/")" . $tpfile # back to top trace otel-cli span \ --name "Tracing Bear Metal" \ --service "$svc" \ --start "$(echo "$talk_start" |sed "s/:00:00/:22:01/")" \ --end "$(echo "$talk_start" |sed "s/:00:00/:28:00/")" otel-cli span \ --name "Recap" \ --service "$svc" \ --start "$(echo "$talk_start" |sed "s/:00:00/:28:01/")" \ --end "$(echo "$talk_start" |sed "s/:00:00/:29:59/")" otel-cli span \ --name "Q & A" \ --service "$svc" \ --start "$(echo "$talk_start" |sed "s/:00:00/:30:00/")" \ --end "$(echo "$talk_start" |sed "s/:00:00/:40:00/")" ================================================ FILE: demos/30-trace-build-process/otel-wrapper-shim.sh ================================================ #!/bin/bash # Build Process Tracing Example # # It's possible to instrument complex build processes in nodejs (npm/yarn/pnpm) and # C (make/cmake/ninja) by injecting instrumented versions of some key commands into $PATH. # # This example is a folder with a shim script in it, and a bunch of fake tools that are just # symlinks to the shim. It needs to be a folder so that it can be cleanly injected into $PATH. # # To see this working, start `otel-desktop-viewer` as discribed in the main README.md, and then: # ( # cd .. && \ # ([ -d mermaid ] || git clone https://github.com/mermaid-js/mermaid) && \ # cd mermaid/ && \ # ../otel-cli/demos/30-trace-build-process/pnpm install # ) # # For more complex build processes, I have found jaeger to be quite good, because it also includes # a black "critical path line". See https://www.jaegertracing.io/docs/1.54/getting-started/ for # their all-in-one docker conainer setup instructions. Otherwise, everything else is the same as # when using otel-desktop-viewer. set -euo pipefail export OTEL_EXPORTER_OTLP_ENDPOINT="${OTEL_EXPORTER_OTLP_ENDPOINT:-localhost:4317}" TOOL_NAME="$(basename $0)" LOCATION_IN_PATH="$(dirname $0)" HERE="$(dirname $(readlink -f $0))" # This is just a guess, based on what's left after we've removed ourselves and any symlinks we know about from the results. # If we're symlinked into $PATH in more than once place then we could still end up in loops. # If this becomes a problem, we could instead iterate over each location and call readlink, # and drop anything that resolves to $HERE. ORIGINAL_TOOL_PATH="$(which -a "$TOOL_NAME" | grep -Ev "($HERE|$LOCATION_IN_PATH)" | head -n1)" # Put this dir first in $PATH so that nested calls out to bash and pnpm are instrumented # (tools like npm have a habit of messing with $PATH to put themselves first in subshells). # This will probably get quite long if there is a lot of recursion. # If this causes problems, we could prune ouselves out before inserting ourself at the head. export PATH="$HERE:$PATH" otel-cli exec --service "$TOOL_NAME" --name "$*" -- $ORIGINAL_TOOL_PATH "$@" ================================================ FILE: docker-compose.yaml ================================================ --- version: '2.1' services: jaeger: image: jaegertracing/all-in-one:1.58.0 ports: - "16686:16686" - "14268" - "14250" otel-collector: image: otel/opentelemetry-collector:0.76.1 volumes: - ./configs/otel-collector.yaml:/local.yaml command: --config /local.yaml ports: - "4317:4317" depends_on: - jaeger ================================================ FILE: example-config.json ================================================ { "endpoint" : "localhost:4317", "traces_endpoint": "", "timeout" : "1s", "otlp_headers" : { "header1" : "header1-value" }, "otlp_blocking" : false, "insecure" : true, "tls_no_verify" : true, "tls_ca_cert": "/dev/null", "tls_client_key": "/dev/null", "tls_client_cert": "/dev/null", "service_name" : "configured_in_config_file", "span_name" : "config_file_span", "span_kind" : "server", "span_attributes" : { "attr1" : "value1" }, "span_end_time" : "now", "span_start_time" : "now", "span_status_code" : "0", "span_status_description" : "status description", "event_name" : "config_file_event", "event_time" : "now", "background_parent_poll_ms" : 100, "background_socket_directory" : "/tmp", "background_wait" : true, "traceparent_carrier_file" : "/tmp/traceparent.txt", "traceparent_ignore_env" : true, "traceparent_print" : true, "traceparent_print_export" : true, "traceparent_required" : false, "verbose" : true, "fail" : true } ================================================ FILE: go.mod ================================================ module github.com/equinix-labs/otel-cli go 1.21 toolchain go1.22.4 require ( github.com/google/go-cmp v0.6.0 github.com/pterm/pterm v0.12.79 github.com/spf13/cobra v1.8.0 go.opentelemetry.io/otel v1.27.0 go.opentelemetry.io/otel/sdk v1.27.0 go.opentelemetry.io/proto/otlp v1.1.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20240610135401-a8a62080eff3 google.golang.org/grpc v1.64.0 google.golang.org/protobuf v1.34.2 ) require ( atomicgo.dev/cursor v0.2.0 // indirect atomicgo.dev/keyboard v0.2.9 // indirect atomicgo.dev/schedule v0.1.0 // indirect github.com/containerd/console v1.0.3 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/gookit/color v1.5.4 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/otel/metric v1.27.0 // indirect go.opentelemetry.io/otel/trace v1.27.0 // indirect golang.org/x/net v0.22.0 // indirect golang.org/x/sys v0.20.0 // indirect golang.org/x/term v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 // indirect ) ================================================ FILE: go.sum ================================================ atomicgo.dev/assert v0.0.2 h1:FiKeMiZSgRrZsPo9qn/7vmr7mCsh5SZyXY4YGYiYwrg= atomicgo.dev/assert v0.0.2/go.mod h1:ut4NcI3QDdJtlmAxQULOmA13Gz6e2DWbSAS8RUOmNYQ= atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw= atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU= atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8= atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ= atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs= atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k= github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI= github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c= github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No= github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg= github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE= github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEejaWgXU= github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE= github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8= github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= github.com/pterm/pterm v0.12.79 h1:lH3yrYMhdpeqX9y5Ep1u7DejyHy7NSQg9qrBjF9dFT4= github.com/pterm/pterm v0.12.79/go.mod h1:1v/gzOF1N0FsjbgTHZ1wVycRkKiatFvJSJC4IGaQAAo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg= go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ= go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik= go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak= go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= go.opentelemetry.io/otel/sdk v1.27.0 h1:mlk+/Y1gLPLn84U4tI8d3GNJmGT/eXe3ZuOXN9kTWmI= go.opentelemetry.io/otel/sdk v1.27.0/go.mod h1:Ha9vbLwJE6W86YstIywK2xFfPjbWlCuwPtMkKdz/Y4A= go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw= go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4= go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI= go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 h1:KAeGQVN3M9nD0/bQXnr/ClcEMJ968gUXJQ9pwfSynuQ= google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80/go.mod h1:cc8bqMqtv9gMOr0zHg2Vzff5ULhhL2IXP4sbcn32Dro= google.golang.org/genproto/googleapis/api v0.0.0-20240123012728-ef4313101c80 h1:Lj5rbfG876hIAYFjqiJnPHfhXbv+nzTWfm04Fg/XSVU= google.golang.org/genproto/googleapis/api v0.0.0-20240123012728-ef4313101c80/go.mod h1:4jWUdICTdgc3Ibxmr8nAJiiLHwQBY0UI0XZcEMaFKaA= google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 h1:RFiFrvy37/mpSpdySBDrUdipW/dHwsRwh3J3+A9VgT4= google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE= google.golang.org/genproto/googleapis/rpc v0.0.0-20240325203815-454cdb8f5daa h1:RBgMaUMP+6soRkik4VoN8ojR2nex2TqZwjSSogic+eo= google.golang.org/genproto/googleapis/rpc v0.0.0-20240325203815-454cdb8f5daa/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= google.golang.org/genproto/googleapis/rpc v0.0.0-20240610135401-a8a62080eff3 h1:9Xyg6I9IWQZhRVfCWjKK+l6kI0jHcPesVlMnT//aHNo= google.golang.org/genproto/googleapis/rpc v0.0.0-20240610135401-a8a62080eff3/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: main.go ================================================ package main import ( "os" "github.com/equinix-labs/otel-cli/otelcli" ) // these will be set by goreleaser & ldflags at build time var ( version = "" commit = "" date = "" ) func main() { otelcli.Execute(otelcli.FormatVersion(version, commit, date)) os.Exit(otelcli.GetExitCode()) } ================================================ FILE: main_test.go ================================================ package main_test // This file implements the data-driven test harness for otel-cli. It executes // tests defined in data_for_test.go, and uses the CA implementation in // tls_for_test.go. // // see TESTING.md for details import ( "bytes" "context" "crypto/tls" "encoding/json" "log" "net" "os" "os/exec" "regexp" "strings" "testing" "time" "github.com/equinix-labs/otel-cli/otlpclient" "github.com/equinix-labs/otel-cli/otlpserver" "github.com/google/go-cmp/cmp" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) // otel-cli will fail with "getent not found" if PATH is empty // so set it to the bare minimum and always the same for cleanup const minimumPath = `/bin:/usr/bin` const defaultTestTimeout = time.Second func TestMain(m *testing.M) { // wipe out this process's envvars right away to avoid pollution & leakage os.Clearenv() result := m.Run() os.Exit(result) } // TestOtelCli iterates over all defined fixtures and executes the tests. func TestOtelCli(t *testing.T) { _, err := os.Stat("./otel-cli") if os.IsNotExist(err) { t.Fatalf("otel-cli must be built and present as ./otel-cli for this test suite to work (try: go build)") } var fixtureCount int for _, suite := range suites { fixtureCount += len(suite) for i, fixture := range suite { // clean up some nils here so the test data can be a bit more terse if fixture.Config.CliArgs == nil { suite[i].Config.CliArgs = []string{} } if fixture.Config.Env == nil { suite[i].Config.Env = map[string]string{} } if fixture.Expect.Env == nil { suite[i].Expect.Env = map[string]string{} } if fixture.Expect.SpanData == nil { suite[i].Expect.SpanData = map[string]string{} } // make sure PATH hasn't been set, because doing that in fixtures is naughty if _, ok := fixture.Config.Env["PATH"]; ok { t.Fatalf("fixture in file %s is not allowed to modify or test envvar PATH", fixture.Name) } } } t.Logf("Running %d test suites and %d fixtures.", len(suites), fixtureCount) if len(suites) == 0 || fixtureCount == 0 { t.Fatal("no test fixtures loaded!") } // generates a CA, client, and server certs to use in tests tlsData := generateTLSData(t) defer tlsData.cleanup() for _, suite := range suites { // a fixture can be backgrounded after starting it up for e.g. otel-cli span background // a second fixture with the same description later in the list will "foreground" it bgFixtureWaits := make(map[string]chan struct{}) bgFixtureDones := make(map[string]chan struct{}) fixtures: for _, fixture := range suite { // some tests explicitly spend time sleeping/waiting to validate timeouts are working // and when they are marked as such, they can be skipped with go test -test.short if testing.Short() && fixture.Config.IsLongTest { t.Skipf("[%s] skipping timeout tests in short mode", fixture.Name) continue fixtures } // inject the TlsData into the fixture fixture.TlsData = tlsData // when a fixture is foregrounded all it does is signal the background fixture // to finish doing its then, waits for it to finish, then continues on if fixture.Config.Foreground { if wait, ok := bgFixtureWaits[fixture.Name]; ok { wait <- struct{}{} delete(bgFixtureWaits, fixture.Name) } else { t.Fatalf("BUG in test or fixture: unexpected foreground fixture wait chan named %q", fixture.Name) } if done, ok := bgFixtureDones[fixture.Name]; ok { <-done delete(bgFixtureDones, fixture.Name) } else { t.Fatalf("BUG in test or fixture: unexpected foreground fixture done chan named %q", fixture.Name) } continue fixtures } // flow control for backgrounding fixtures: fixtureWait := make(chan struct{}) fixtureDone := make(chan struct{}) go runFixture(t, fixture, fixtureWait, fixtureDone) if fixture.Config.Background { // save off the channels for flow control t.Logf("[%s] fixture %q backgrounded", fixture.Name, fixture.Name) bgFixtureWaits[fixture.Name] = fixtureWait bgFixtureDones[fixture.Name] = fixtureDone } else { // actually the default case, just block as if the code was ran synchronously fixtureWait <- struct{}{} <-fixtureDone } } } } // runFixture runs the OTLP server & command, waits for signal, checks // results, then signals it's done. func runFixture(t *testing.T, fixture Fixture, wait, done chan struct{}) { // sets up an OTLP server, runs otel-cli, packages data up in these return vals endpoint, results := runOtelCli(t, fixture) <-wait // inject the runtime endpoint into the fixture fixture.Endpoint = endpoint checkAll(t, fixture, results) done <- struct{}{} } // checkAll gathers up all the check* funcs below into one function. func checkAll(t *testing.T, fixture Fixture, results Results) { // check timeout and process status expectations success := checkProcess(t, fixture, results) // when the process fails, no point in checking the rest, it's just noise if !success { t.Log("otel-cli process failed unexpectedly, will not test values from it") return } // compare the number of recorded spans against expectations in the fixture checkSpanCount(t, fixture, results) // compares the data in each recorded span against expectations in the fixture if len(fixture.Expect.SpanData) > 0 { checkSpanData(t, fixture, results) } // many of the basic plumbing tests use status so it has its own set of checks // but these shouldn't run for testing the other subcommands if len(fixture.Config.CliArgs) > 0 && fixture.Config.CliArgs[0] == "status" && results.ExitCode == 0 { checkStatusData(t, fixture, results) } else { // checking the text output only makes sense for non-status paths checkOutput(t, fixture, results) } if len(fixture.Expect.Headers) > 0 { checkHeaders(t, fixture, results) } if len(fixture.Expect.ServerMeta) > 0 { checkServerMeta(t, fixture, results) } checkFuncs(t, fixture, results) } // compare the number of spans recorded by the test server against the // number of expected spans in the fixture, if specified. If no expected // span count is specified, this check always passes. func checkSpanCount(t *testing.T, fixture Fixture, results Results) { if results.SpanCount != fixture.Expect.SpanCount { t.Errorf("[%s] span count was %d but expected %d", fixture.Name, results.SpanCount, fixture.Expect.SpanCount) } } // checkProcess validates configured expectations about whether otel-cli failed // or the test timed out. These are mostly used for testing that otel-cli fails // in the way we want it to. func checkProcess(t *testing.T, fixture Fixture, results Results) bool { if results.TimedOut != fixture.Expect.TimedOut { t.Errorf("[%s] test timeout status is %t but expected %t", fixture.Name, results.TimedOut, fixture.Expect.TimedOut) return false } if results.CommandFailed != fixture.Expect.CommandFailed { t.Errorf("[%s] command failed is %t but expected %t", fixture.Name, results.CommandFailed, fixture.Expect.CommandFailed) return false } return true } // checkOutput looks that otel-cli output stored in the results and compares against // the fixture expectation (with {{endpoint}} replaced). func checkOutput(t *testing.T, fixture Fixture, results Results) { wantOutput := injectVars(fixture.Expect.CliOutput, fixture.Endpoint, fixture.TlsData) gotOutput := results.CliOutput if fixture.Expect.CliOutputRe != nil { gotOutput = fixture.Expect.CliOutputRe.ReplaceAllString(gotOutput, "") } if diff := cmp.Diff(wantOutput, gotOutput); diff != "" { if fixture.Expect.CliOutputRe != nil { t.Errorf("[%s] test fixture RE modified output from %q to %q", fixture.Name, fixture.Expect.CliOutput, gotOutput) } t.Errorf("[%s] otel-cli output did not match fixture (-want +got):\n%s", fixture.Name, diff) } } // checkStatusData compares the sections of otel-cli status output against // fixture data, substituting {{endpoint}} into fixture data as needed. func checkStatusData(t *testing.T, fixture Fixture, results Results) { // check the env injectMapVars(fixture.Endpoint, fixture.Expect.Env, fixture.TlsData) if diff := cmp.Diff(fixture.Expect.Env, results.Env); diff != "" { t.Errorf("env data did not match fixture in %q (-want +got):\n%s", fixture.Name, diff) } // check diagnostics, use string maps so the diff output is easy to compare to json wantDiag := fixture.Expect.Diagnostics.ToStringMap() gotDiag := results.Diagnostics.ToStringMap() injectMapVars(fixture.Endpoint, wantDiag, fixture.TlsData) // there's almost always going to be cli_args in practice, so if the fixture has // an empty string, just ignore that key if wantDiag["cli_args"] == "" { gotDiag["cli_args"] = "" } for k, v := range wantDiag { if v == "*" { wantDiag[k] = gotDiag[k] } } if diff := cmp.Diff(wantDiag, gotDiag); diff != "" { t.Errorf("[%s] diagnostic data did not match fixture (-want +got):\n%s", fixture.Name, diff) } // check the configuration wantConf := fixture.Expect.Config.ToStringMap() gotConf := results.Config.ToStringMap() // if an expected config string is set to "*" it will match anything // and is effectively ignored for k, v := range wantConf { if v == "*" { // set to same so cmd.Diff will ignore wantConf[k] = gotConf[k] } } injectMapVars(fixture.Endpoint, wantConf, fixture.TlsData) if diff := cmp.Diff(wantConf, gotConf); diff != "" { t.Errorf("[%s] config data did not match fixture (-want +got):\n%s", fixture.Name, diff) } } // checkSpanData compares the data in spans received from otel-cli against the // fixture data. func checkSpanData(t *testing.T, fixture Fixture, results Results) { // check the expected span data against what was received by the OTLP server gotSpan := otlpclient.SpanToStringMap(results.Span, results.ResourceSpans) injectMapVars(fixture.Endpoint, gotSpan, fixture.TlsData) wantSpan := map[string]string{} // to be passed to cmp.Diff // verify all fields that were expected were present in output span for what := range fixture.Expect.SpanData { if _, ok := gotSpan[what]; !ok { t.Errorf("[%s] expected span to have key %q but it is not present", fixture.Name, what) } } // spanRegexChecks is a map of field names and regexes for basic presence // and validity checking of span data fields with expected set to "*" spanRegexChecks := map[string]*regexp.Regexp{ "trace_id": regexp.MustCompile(`^[0-9a-fA-F]{32}$`), "span_id": regexp.MustCompile(`^[0-9a-fA-F]{16}$`), "name": regexp.MustCompile(`^\w+$`), "parent": regexp.MustCompile(`^[0-9a-fA-F]{32}$`), "kind": regexp.MustCompile(`^\w+$`), // TODO: can validate more here "start": regexp.MustCompile(`^\d+$`), "end": regexp.MustCompile(`^\d+$`), "attributes": regexp.MustCompile(`\w+=.+`), // not great but should validate at least one k=v } // go over all the keys in the received span and check against expected values // in the fixture, and the spanRegexChecks // modifies the gotSpan/wantSpan maps so cmp.Diff can do its thing for what, gotVal := range gotSpan { var wantVal string var ok bool if wantVal, ok = fixture.Expect.SpanData[what]; ok { wantSpan[what] = wantVal } else { wantSpan[what] = gotVal // make a straight copy to make cmp.Diff happy } if re, ok := spanRegexChecks[what]; ok { if wantVal == "*" { // * means if the above RE returns cleanly then pass the test if re.MatchString(gotVal) { delete(gotSpan, what) delete(wantSpan, what) } else { t.Errorf("[%s] span value %q for key %s is not valid", fixture.Name, gotVal, what) } } } } injectMapVars(fixture.Endpoint, wantSpan, fixture.TlsData) // a regular expression can be put in e.g. /^foo$/ to get evaluated as RE for key, wantVal := range wantSpan { if strings.HasPrefix(wantVal, "/") && strings.HasSuffix(wantVal, "/") { re := regexp.MustCompile(wantVal[1 : len(wantVal)-1]) // slice strips the /'s off if !re.MatchString(gotSpan[key]) { t.Errorf("regular expression %q did not match on %q", wantVal, gotSpan[key]) } delete(gotSpan, key) // delete from both maps so cmp.Diff ignores them delete(wantSpan, key) } } // do a diff on a generated map that sets values to * when the * check succeeded if diff := cmp.Diff(wantSpan, gotSpan); diff != "" { t.Errorf("[%s] otel span info did not match fixture (-want +got):\n%s", fixture.Name, diff) } } // checkHeaders compares the expected and received headers. func checkHeaders(t *testing.T, fixture Fixture, results Results) { // gzip encoding makes automatically comparing values tricky, so ignore it // unless the test actually specifies a length if _, ok := fixture.Expect.Headers["Content-Length"]; !ok { delete(results.Headers, "Content-Length") } injectMapVars(fixture.Endpoint, fixture.Expect.Headers, fixture.TlsData) injectMapVars(fixture.Endpoint, results.Headers, fixture.TlsData) for k, v := range fixture.Expect.Headers { if v == "*" { // overwrite value so cmp.Diff will ignore results.Headers[k] = "*" } } if diff := cmp.Diff(fixture.Expect.Headers, results.Headers); diff != "" { t.Errorf("[%s] headers did not match expected (-want +got):\n%s", fixture.Name, diff) } } // checkServerMeta compares the expected and received server metadata. func checkServerMeta(t *testing.T, fixture Fixture, results Results) { injectMapVars(fixture.Endpoint, fixture.Expect.ServerMeta, fixture.TlsData) injectMapVars(fixture.Endpoint, results.ServerMeta, fixture.TlsData) if diff := cmp.Diff(fixture.Expect.ServerMeta, results.ServerMeta); diff != "" { t.Errorf("[%s] server metadata did not match expected (-want +got):\n%s", fixture.Name, diff) } } // checkFuncs runs through the list of funcs in the fixture to do // complex checking on data. Funcs should call t.Errorf, etc. as needed. func checkFuncs(t *testing.T, fixture Fixture, results Results) { for _, f := range fixture.CheckFuncs { f(t, fixture, results) } } // runOtelCli runs the a server and otel-cli together and captures their // output as data to return for further testing. func runOtelCli(t *testing.T, fixture Fixture) (string, Results) { started := time.Now() results := Results{ SpanData: map[string]string{}, Env: map[string]string{}, SpanEvents: []*tracepb.Span_Event{}, } // these channels need to be buffered or the callback will hang trying to send rcvSpan := make(chan *tracepb.Span, 100) // 100 spans is enough for anybody rcvEvents := make(chan []*tracepb.Span_Event, 100) // otlpserver calls this function for each span received cb := func(ctx context.Context, span *tracepb.Span, events []*tracepb.Span_Event, rss *tracepb.ResourceSpans, headers map[string]string, meta map[string]string) bool { rcvSpan <- span rcvEvents <- events results.ServerMeta = meta results.ResourceSpans = rss results.SpanCount++ results.EventCount += len(events) results.Headers = headers // true tells the server we're done and it can exit its loop return results.SpanCount >= fixture.Expect.SpanCount } var cs otlpserver.OtlpServer switch fixture.Config.ServerProtocol { case grpcProtocol: cs = otlpserver.NewServer("grpc", cb, func(otlpserver.OtlpServer) {}) case httpProtocol: cs = otlpserver.NewServer("http", cb, func(otlpserver.OtlpServer) {}) } defer cs.Stop() serverTimeout := time.Duration(fixture.Config.TestTimeoutMs) * time.Millisecond if serverTimeout == time.Duration(0) { serverTimeout = defaultTestTimeout } // start a timeout goroutine for the otlp server, cancelable with done<-struct{}{} cancelServerTimeout := make(chan struct{}, 1) go func() { select { case <-time.After(serverTimeout): results.TimedOut = true cs.Stop() // supports multiple calls case <-cancelServerTimeout: return } }() // port :0 means randomly assigned port, which we copy into {{endpoint}} var listener net.Listener var err error if fixture.Config.ServerTLSEnabled { tlsConf := fixture.TlsData.serverTLSConf.Clone() if fixture.Config.ServerTLSAuthEnabled { tlsConf.ClientAuth = tls.RequireAndVerifyClientCert } listener, err = tls.Listen("tcp", "localhost:0", tlsConf) } else { listener, err = net.Listen("tcp", "localhost:0") } endpoint := listener.Addr().String() if err != nil { // t.Fatalf is not allowed since we run this in a goroutine t.Errorf("[%s] failed to listen on OTLP endpoint %q: %s", fixture.Name, endpoint, err) return endpoint, results } t.Logf("[%s] starting OTLP server on %q", fixture.Name, endpoint) // TODO: might be neat to have a mode where we start the listener and do nothing // with it to simulate a hung server or opentelemetry-collector go func() { cs.Serve(listener) }() // let things go this far to generate the endpoint port then stop the server before // calling otel-cli so we can test timeouts if fixture.Config.StopServerBeforeExec { cs.Stop() listener.Close() } // TODO: figure out the best way to build the binary and detect if the build is stale // ^^ probably doesn't matter much in CI, can auto-build, but for local workflow it matters // TODO: should all otel-cli commands be able to dump status? e.g. otel-cli span --status args := []string{} if len(fixture.Config.CliArgs) > 0 { for _, v := range fixture.Config.CliArgs { args = append(args, injectVars(v, endpoint, fixture.TlsData)) } } statusCmd := exec.Command("./otel-cli", args...) statusCmd.Env = mkEnviron(endpoint, fixture.Config.Env, fixture.TlsData) // have command write output into string buffers var cliOut bytes.Buffer statusCmd.Stdout = &cliOut statusCmd.Stderr = &cliOut err = statusCmd.Start() if err != nil { t.Fatalf("[%s] error starting otel-cli: %s", fixture.Name, err) } stopKiller := make(chan struct{}, 1) if fixture.Config.KillAfter != 0 { go func() { select { case <-time.After(fixture.Config.KillAfter): err := statusCmd.Process.Signal(fixture.Config.KillSignal) if err != nil { log.Fatalf("[%s] error sending signal %s to pid %d: %s", fixture.Name, fixture.Config.KillSignal, statusCmd.Process.Pid, err) } case <-stopKiller: return } }() } else { go func() { select { case <-time.After(serverTimeout): t.Logf("[%s] timeout, killing process...", fixture.Name) results.TimedOut = true err = statusCmd.Process.Kill() if err != nil { // TODO: this might be a bit fragile, soften this up later if it ends up problematic log.Fatalf("[%s] %d timeout process kill failed: %s", fixture.Name, serverTimeout, err) } case <-stopKiller: return } }() } // grab stderr & stdout comingled so that if otel-cli prints anything to either it's not // supposed to it will cause e.g. status json parsing and other tests to fail t.Logf("[%s] going to exec 'env -i %s %s'", fixture.Name, strings.Join(statusCmd.Env, " "), strings.Join(statusCmd.Args, " ")) err = statusCmd.Wait() results.CliOutput = cliOut.String() results.ExitCode = statusCmd.ProcessState.ExitCode() results.CommandFailed = !statusCmd.ProcessState.Exited() if err != nil { t.Logf("[%s] command exited: %s", fixture.Name, err) } // send stop signals to the timeouts and OTLP server cancelServerTimeout <- struct{}{} stopKiller <- struct{}{} cs.Stop() // only try to parse status json if it was a status command // TODO: support variations on otel-cli where status isn't the first arg if len(args) > 0 && args[0] == "status" && results.ExitCode == 0 { err = json.Unmarshal(cliOut.Bytes(), &results) if err != nil { t.Errorf("[%s] parsing otel-cli status output failed: %s", fixture.Name, err) t.Logf("[%s] output received: %q", fixture.Name, cliOut) return endpoint, results } // remove PATH from the output but only if it's exactly what we set on exec if path, ok := results.Env["PATH"]; ok { if path == minimumPath { delete(results.Env, "PATH") } } } // when no spans are expected, return without reading from the channels if fixture.Expect.SpanCount == 0 { return endpoint, results } // grab the spans & events from the server off the channels it writes to remainingTimeout := serverTimeout - time.Since(started) var gatheredSpans int gather: for { select { case <-time.After(remainingTimeout): break gather case results.Span = <-rcvSpan: // events is always populated at the same time as the span is sent // and will always send at least an empty list results.SpanEvents = <-rcvEvents // with this approach, any mismatch in spans produced and expected results // in a timeout with the above time.After gatheredSpans++ if gatheredSpans == results.SpanCount { // TODO: it would be slightly nicer to use plural.Selectf instead of 'span(s)' t.Logf("[%s] test gathered %d span(s)", fixture.Name, gatheredSpans) break gather } } } return endpoint, results } // mkEnviron converts a string map to a list of k=v strings and tacks on PATH. func mkEnviron(endpoint string, env map[string]string, tlsData TlsSettings) []string { mapped := make([]string, len(env)+1) var i int for k, v := range env { mapped[i] = k + "=" + injectVars(v, endpoint, tlsData) i++ } // always tack on a PATH otherwise the binary will fail with no PATH // to get to getent(1) mapped[len(mapped)-1] = "PATH=" + minimumPath return mapped } // injectMapVars iterates over the map and updates the values, replacing all instances // of {{endpoint}}, {{tls_ca_cert}}, {{tls_client_cert}}, and {{tls_client_key}} with // test values. func injectMapVars(endpoint string, target map[string]string, tlsData TlsSettings) { for k, v := range target { target[k] = injectVars(v, endpoint, tlsData) } } // injectVars replaces all instances of {{endpoint}}, {{tls_ca_cert}}, // {{tls_client_cert}}, and {{tls_client_key}} with test values. // This is needed because the otlpserver is configured to listen on :0 which has it // grab a random port. Once that's generated we need to inject it into all the values // so the test comparisons work as expected. Similarly for TLS testing, a temp CA and // certs are created and need to be injected. func injectVars(in, endpoint string, tlsData TlsSettings) string { out := strings.ReplaceAll(in, "{{endpoint}}", endpoint) out = strings.ReplaceAll(out, "{{tls_ca_cert}}", tlsData.caFile) out = strings.ReplaceAll(out, "{{tls_client_cert}}", tlsData.clientFile) out = strings.ReplaceAll(out, "{{tls_client_key}}", tlsData.clientPrivKeyFile) return out } ================================================ FILE: otelcli/completion.go ================================================ package otelcli import ( "log" "os" "github.com/spf13/cobra" ) func completionCmd(*Config) *cobra.Command { cmd := cobra.Command{ Use: "completion [bash|zsh|fish|powershell]", Short: "Generate completion script", Long: `To load completions: Bash: $ source <(otel-cli completion bash) # To load completions for each session, execute once: # Linux: $ otel-cli completion bash > /etc/bash_completion.d/otel-cli # macOS: $ otel-cli completion bash > /usr/local/etc/bash_completion.d/otel-cli Zsh: # If shell completion is not already enabled in your environment, # you will need to enable it. You can execute the following once: $ echo "autoload -U compinit; compinit" >> ~/.zshrc # To load completions for each session, execute once: $ otel-cli completion zsh > "${fpath[1]}/_otel-cli" # You will need to start a new shell for this setup to take effect. fish: $ otel-cli completion fish | source # To load completions for each session, execute once: $ otel-cli completion fish > ~/.config/fish/completions/otel-cli.fish PowerShell: PS> otel-cli completion powershell | Out-String | Invoke-Expression # To load completions for every new session, run: PS> otel-cli completion powershell > otel-cli.ps1 # and source this file from your PowerShell profile. `, DisableFlagsInUseLine: true, ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, Args: cobra.MatchAll(cobra.ExactArgs(1)), Run: func(cmd *cobra.Command, args []string) { switch args[0] { case "bash": err := cmd.Root().GenBashCompletion(os.Stdout) if err != nil { log.Fatalf("failed to write completion to stdout") } case "zsh": err := cmd.Root().GenZshCompletion(os.Stdout) if err != nil { log.Fatalf("failed to write completion to stdout") } case "fish": err := cmd.Root().GenFishCompletion(os.Stdout, true) if err != nil { log.Fatalf("failed to write completion to stdout") } case "powershell": err := cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) if err != nil { log.Fatalf("failed to write completion to stdout") } } }, } return &cmd } ================================================ FILE: otelcli/config.go ================================================ package otelcli import ( "encoding/csv" "encoding/json" "errors" "fmt" "log" "net/url" "os" "path" "reflect" "regexp" "sort" "strconv" "strings" "time" ) var detectBrokenRFC3339PrefixRe *regexp.Regexp var epochNanoTimeRE *regexp.Regexp func init() { detectBrokenRFC3339PrefixRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2} `) epochNanoTimeRE = regexp.MustCompile(`^\d+\.\d+$`) } // DefaultConfig returns a Config with all defaults set. func DefaultConfig() Config { return Config{ Endpoint: "", Protocol: "", Timeout: "1s", Headers: map[string]string{}, Insecure: false, Blocking: false, TlsNoVerify: false, TlsCACert: "", TlsClientKey: "", TlsClientCert: "", ServiceName: "otel-cli", SpanName: "todo-generate-default-span-names", Kind: "client", ForceTraceId: "", ForceSpanId: "", ForceParentSpanId: "", Attributes: map[string]string{}, TraceparentCarrierFile: "", TraceparentIgnoreEnv: false, TraceparentPrint: false, TraceparentPrintExport: false, TraceparentRequired: false, BackgroundParentPollMs: 10, BackgroundSockdir: "", BackgroundWait: false, BackgroundSkipParentPidCheck: false, ExecCommandTimeout: "", ExecTpDisableInject: false, StatusCanaryCount: 1, StatusCanaryInterval: "", SpanStartTime: "now", SpanEndTime: "now", EventName: "todo-generate-default-event-names", EventTime: "now", CfgFile: "", Verbose: false, Fail: false, StatusCode: "unset", StatusDescription: "", Version: "unset", } } // Config stores the runtime configuration for otel-cli. // Data structure is public so that it can serialize to json easily. type Config struct { Endpoint string `json:"endpoint" env:"OTEL_EXPORTER_OTLP_ENDPOINT"` TracesEndpoint string `json:"traces_endpoint" env:"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"` Protocol string `json:"protocol" env:"OTEL_EXPORTER_OTLP_PROTOCOL,OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"` Timeout string `json:"timeout" env:"OTEL_EXPORTER_OTLP_TIMEOUT,OTEL_EXPORTER_OTLP_TRACES_TIMEOUT"` Headers map[string]string `json:"otlp_headers" env:"OTEL_EXPORTER_OTLP_HEADERS"` // TODO: needs json marshaler hook to mask tokens Insecure bool `json:"insecure" env:"OTEL_EXPORTER_OTLP_INSECURE"` Blocking bool `json:"otlp_blocking" env:"OTEL_EXPORTER_OTLP_BLOCKING"` TlsCACert string `json:"tls_ca_cert" env:"OTEL_EXPORTER_OTLP_CERTIFICATE,OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE"` TlsClientKey string `json:"tls_client_key" env:"OTEL_EXPORTER_OTLP_CLIENT_KEY,OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY"` TlsClientCert string `json:"tls_client_cert" env:"OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE,OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE"` // OTEL_CLI_NO_TLS_VERIFY is deprecated and will be removed for 1.0 TlsNoVerify bool `json:"tls_no_verify" env:"OTEL_CLI_TLS_NO_VERIFY,OTEL_CLI_NO_TLS_VERIFY"` ServiceName string `json:"service_name" env:"OTEL_CLI_SERVICE_NAME,OTEL_SERVICE_NAME"` SpanName string `json:"span_name" env:"OTEL_CLI_SPAN_NAME"` Kind string `json:"span_kind" env:"OTEL_CLI_TRACE_KIND"` Attributes map[string]string `json:"span_attributes" env:"OTEL_CLI_ATTRIBUTES"` StatusCode string `json:"span_status_code" env:"OTEL_CLI_STATUS_CODE"` StatusDescription string `json:"span_status_description" env:"OTEL_CLI_STATUS_DESCRIPTION"` ForceSpanId string `json:"force_span_id" env:"OTEL_CLI_FORCE_SPAN_ID"` ForceParentSpanId string `json:"force_parent_span_id" env:"OTEL_CLI_FORCE_PARENT_SPAN_ID"` ForceTraceId string `json:"force_trace_id" env:"OTEL_CLI_FORCE_TRACE_ID"` TraceparentCarrierFile string `json:"traceparent_carrier_file" env:"OTEL_CLI_CARRIER_FILE"` TraceparentIgnoreEnv bool `json:"traceparent_ignore_env" env:"OTEL_CLI_IGNORE_ENV"` TraceparentPrint bool `json:"traceparent_print" env:"OTEL_CLI_PRINT_TRACEPARENT"` TraceparentPrintExport bool `json:"traceparent_print_export" env:"OTEL_CLI_EXPORT_TRACEPARENT"` TraceparentRequired bool `json:"traceparent_required" env:"OTEL_CLI_TRACEPARENT_REQUIRED"` BackgroundParentPollMs int `json:"background_parent_poll_ms" env:""` BackgroundSockdir string `json:"background_socket_directory" env:""` BackgroundWait bool `json:"background_wait" env:""` BackgroundSkipParentPidCheck bool `json:"background_skip_parent_pid_check"` ExecCommandTimeout string `json:"exec_command_timeout" env:"OTEL_CLI_EXEC_CMD_TIMEOUT"` ExecTpDisableInject bool `json:"exec_tp_disable_inject" env:"OTEL_CLI_EXEC_TP_DISABLE_INJECT"` StatusCanaryCount int `json:"status_canary_count"` StatusCanaryInterval string `json:"status_canary_interval"` SpanStartTime string `json:"span_start_time" env:""` SpanEndTime string `json:"span_end_time" env:""` EventName string `json:"event_name" env:""` EventTime string `json:"event_time" env:""` CfgFile string `json:"config_file" env:"OTEL_CLI_CONFIG_FILE"` Verbose bool `json:"verbose" env:"OTEL_CLI_VERBOSE"` Fail bool `json:"fail" env:"OTEL_CLI_FAIL"` // not exported, used to get data from cobra to otlpclient internals Version string `json:"-"` } // LoadFile reads the file specified by -c/--config and overwrites the // current config values with any found in the file. func (c *Config) LoadFile() error { if c.CfgFile == "" { return nil } js, err := os.ReadFile(c.CfgFile) if err != nil { return fmt.Errorf("failed to read file '%s': %w", c.CfgFile, err) } if err := json.Unmarshal(js, c); err != nil { return fmt.Errorf("failed to parse json data in file '%s': %w", c.CfgFile, err) } return nil } // LoadEnv loads environment variables into the config, overwriting current // values. Environment variable to config key mapping is tagged on the // Config struct. Multiple names for envvars is supported, comma-separated. // Takes a func(string)string that's usually os.Getenv, and is swappable to // make testing easier. func (c *Config) LoadEnv(getenv func(string) string) error { // loop over each field of the Config structType := reflect.TypeOf(c).Elem() cValue := reflect.ValueOf(c).Elem() for i := 0; i < structType.NumField(); i++ { field := structType.Field(i) envVars := field.Tag.Get("env") if envVars == "" { continue } // a field can have multiple comma-delimiated env vars to look in for _, envVar := range strings.Split(envVars, ",") { // call the provided func(string)string provided to get the // envvar, usually os.Getenv but can be a fake for testing envVal := getenv(envVar) if envVal == "" { continue } // type switch and write the value into the struct target := cValue.Field(i) switch target.Interface().(type) { case string: target.SetString(envVal) case int: intVal, err := strconv.ParseInt(envVal, 10, 64) if err != nil { return fmt.Errorf("could not parse %s value %q as an int: %w", envVar, envVal, err) } target.SetInt(intVal) case bool: boolVal, err := strconv.ParseBool(envVal) if err != nil { return fmt.Errorf("could not parse %s value %q as an bool: %w", envVar, envVal, err) } target.SetBool(boolVal) case map[string]string: mapVal, err := parseCkvStringMap(envVal) if err != nil { return fmt.Errorf("could not parse %s value %q as a map: %w", envVar, envVal, err) } mapValVal := reflect.ValueOf(mapVal) target.Set(mapValVal) } } } return nil } // ToStringMap flattens the configuration into a stringmap that is easy to work // with in tests especially with cmp.Diff. See test_main.go. func (c Config) ToStringMap() map[string]string { return map[string]string{ "endpoint": c.Endpoint, "protocol": c.Protocol, "timeout": c.Timeout, "headers": flattenStringMap(c.Headers, "{}"), "insecure": strconv.FormatBool(c.Insecure), "blocking": strconv.FormatBool(c.Blocking), "tls_no_verify": strconv.FormatBool(c.TlsNoVerify), "tls_ca_cert": c.TlsCACert, "tls_client_key": c.TlsClientKey, "tls_client_cert": c.TlsClientCert, "service_name": c.ServiceName, "span_name": c.SpanName, "span_kind": c.Kind, "span_attributes": flattenStringMap(c.Attributes, "{}"), "span_status_code": c.StatusCode, "span_status_description": c.StatusDescription, "traceparent_carrier_file": c.TraceparentCarrierFile, "traceparent_ignore_env": strconv.FormatBool(c.TraceparentIgnoreEnv), "traceparent_print": strconv.FormatBool(c.TraceparentPrint), "traceparent_print_export": strconv.FormatBool(c.TraceparentPrintExport), "traceparent_required": strconv.FormatBool(c.TraceparentRequired), "background_parent_poll_ms": strconv.Itoa(c.BackgroundParentPollMs), "background_socket_directory": c.BackgroundSockdir, "background_wait": strconv.FormatBool(c.BackgroundWait), "background_skip_pid_check": strconv.FormatBool(c.BackgroundSkipParentPidCheck), "exec_command_timeout": c.ExecCommandTimeout, "exec_tp_disable_inject": strconv.FormatBool(c.ExecTpDisableInject), "span_start_time": c.SpanStartTime, "span_end_time": c.SpanEndTime, "event_name": c.EventName, "event_time": c.EventTime, "config_file": c.CfgFile, "verbose": strconv.FormatBool(c.Verbose), } } // GetIsRecording returns true if an endpoint is set and otel-cli expects to send real // spans. Returns false if unconfigured and going to run inert. func (c Config) GetIsRecording() bool { if c.Endpoint == "" && c.TracesEndpoint == "" { Diag.IsRecording = false return false } Diag.IsRecording = true return true } // ParseCliTimeout parses the --timeout string value to a time.Duration. func (c Config) ParseCliTimeout() time.Duration { out, err := parseDuration(c.Timeout) Diag.ParsedTimeoutMs = out.Milliseconds() c.SoftFailIfErr(err) return out } // ParseExecCommandTimeout parses the --command-timeout string value to a time.Duration. // When timeout is unspecified or 0, otel-cli will wait forever for the command to complete. func (c Config) ParseExecCommandTimeout() time.Duration { if c.ExecCommandTimeout == "" { return 0 } out, err := parseDuration(c.ExecCommandTimeout) c.SoftFailIfErr(err) return out } // ParseStatusCanaryInterval parses the --canary-interval string value to a time.Duration. func (c Config) ParseStatusCanaryInterval() time.Duration { out, err := parseDuration(c.StatusCanaryInterval) c.SoftFailIfErr(err) return out } // parseDuration parses a string duration into a time.Duration. // When no duration letter is provided (e.g. ms, s, m, h), seconds are assumed. // It logs an error and returns time.Duration(0) if the string is empty or unparseable. func parseDuration(d string) (time.Duration, error) { var out time.Duration if d == "" { out = time.Duration(0) } else if parsed, err := time.ParseDuration(d); err == nil { out = parsed } else if secs, serr := strconv.ParseInt(d, 10, 0); serr == nil { out = time.Second * time.Duration(secs) } else { return time.Duration(0), fmt.Errorf("unable to parse duration string %q: %w", d, err) } return out, nil } // ParseEndpoint takes the endpoint or signal endpoint, augments as needed // (e.g. bare host:port for gRPC) and then parses as a URL. // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md#endpoint-urls-for-otlphttp func (config Config) ParseEndpoint() (*url.URL, string) { var endpoint, source string var epUrl *url.URL var err error // signal-specific configs get precedence over general endpoint per OTel spec if config.TracesEndpoint != "" { endpoint = config.TracesEndpoint source = "signal" } else if config.Endpoint != "" { endpoint = config.Endpoint source = "general" } else { config.SoftFail("no endpoint configuration available") } parts := strings.Split(endpoint, ":") // bare hostname? can only be grpc, prepend if len(parts) == 1 { epUrl, err = url.Parse("grpc://" + endpoint + ":4317") if err != nil { config.SoftFail("error parsing (assumed) gRPC bare host address '%s': %s", endpoint, err) } } else if len(parts) > 1 { // could be URI or host:port // actual URIs // grpc:// is only an otel-cli thing, maybe should drop it? if parts[0] == "grpc" || parts[0] == "http" || parts[0] == "https" { epUrl, err = url.Parse(endpoint) if err != nil { config.SoftFail("error parsing provided %s URI '%s': %s", source, endpoint, err) } } else { // gRPC host:port epUrl, err = url.Parse("grpc://" + endpoint) if err != nil { config.SoftFail("error parsing (assumed) gRPC host:port address '%s': %s", endpoint, err) } } } // Per spec, /v1/traces is the default, appended to any url passed // to the general endpoint if strings.HasPrefix(epUrl.Scheme, "http") && source != "signal" && !strings.HasSuffix(epUrl.Path, "/v1/traces") { epUrl.Path = path.Join(epUrl.Path, "/v1/traces") } Diag.EndpointSource = source Diag.Endpoint = epUrl.String() return epUrl, source } // SoftLog only calls through to log if otel-cli was run with the --verbose flag. // TODO: does it make any sense to support %w? probably yes, can clean up some // diagnostics.Error touch points. func (c Config) SoftLog(format string, a ...interface{}) { if !c.Verbose { return } log.Printf(format, a...) } // SoftLogIfErr calls SoftLog only if err != nil. // Written as an interim step to pushing errors up the stack instead of calling // SoftLog/SoftFail directly in methods that don't need a config handle. func (c Config) SoftLogIfErr(err error) { if err != nil { c.SoftLog(err.Error()) } } // SoftFail calls through to softLog (which logs only if otel-cli was run with the --verbose // flag), then immediately exits - with status -1 by default, or 1 if --fail was // set (a la `curl --fail`) func (c Config) SoftFail(format string, a ...interface{}) { c.SoftLog(format, a...) if c.Fail { os.Exit(1) } else { os.Exit(0) } } // SoftFailIfErr calls SoftFail only if err != nil. // Written as an interim step to pushing errors up the stack instead of calling // SoftLog/SoftFail directly in methods that don't need a config handle. func (c Config) SoftFailIfErr(err error) { if err != nil { c.SoftFail(err.Error()) } } // flattenStringMap takes a string map and returns it flattened into a string with // keys sorted lexically so it should be mostly consistent enough for comparisons // and printing. Output is k=v,k=v style like attributes input. func flattenStringMap(mp map[string]string, emptyValue string) string { if len(mp) == 0 { return emptyValue } var out string keys := make([]string, len(mp)) // for sorting var i int for k := range mp { keys[i] = k i++ } sort.Strings(keys) for i, k := range keys { out = out + k + "=" + mp[k] if i == len(keys)-1 { break } out = out + "," } return out } // parseCkvStringMap parses key=value,foo=bar formatted strings as a line of CSV // and returns it as a string map. func parseCkvStringMap(in string) (map[string]string, error) { r := csv.NewReader(strings.NewReader(in)) pairs, err := r.Read() if err != nil { return map[string]string{}, err } out := make(map[string]string) for _, pair := range pairs { parts := strings.SplitN(pair, "=", 2) if parts[0] != "" && parts[1] != "" { out[parts[0]] = parts[1] } else { return map[string]string{}, fmt.Errorf("kv pair %s must be in key=value format", pair) } } return out, nil } // ParseSpanStartTime returns config.SpanStartTime as time.Time. func (c Config) ParseSpanStartTime() time.Time { t, err := c.parseTime(c.SpanStartTime, "start") c.SoftFailIfErr(err) return t } // ParseSpanEndTime returns config.SpanEndTime as time.Time. func (c Config) ParseSpanEndTime() time.Time { t, err := c.parseTime(c.SpanEndTime, "end") c.SoftFailIfErr(err) return t } // ParsedEventTime returns config.EventTime as time.Time. func (c Config) ParsedEventTime() time.Time { t, err := c.parseTime(c.EventTime, "event") c.SoftFailIfErr(err) return t } // parseTime tries to parse Unix epoch, then RFC3339, both with/without nanoseconds func (c Config) parseTime(ts, which string) (time.Time, error) { // errors accumulate as parsing methods are attempted // thrown away when one succeeds, joined & returned if none succeed errs := []error{} if ts == "now" { return time.Now(), nil } // Unix epoch time if i, err := strconv.ParseInt(ts, 10, 64); err == nil { return time.Unix(i, 0), nil } else { errs = append(errs, fmt.Errorf("could not parse span %s time %q as Unix Epoch: %w", which, ts, err)) } // date --rfc-3339 returns an invalid format for Go because it has a // space instead of 'T' between date and time if detectBrokenRFC3339PrefixRe.MatchString(ts) { ts = strings.Replace(ts, " ", "T", 1) } // Unix epoch time with nanoseconds if epochNanoTimeRE.MatchString(ts) { parts := strings.Split(ts, ".") if len(parts) == 2 { secs, secsErr := strconv.ParseInt(parts[0], 10, 64) nsecs, usecsErr := strconv.ParseInt(parts[1], 10, 64) if secsErr == nil && usecsErr == nil && secs > 0 { return time.Unix(secs, nsecs), nil } else { errs = append(errs, fmt.Errorf("could not parse span %s time %q as Unix Epoch: %w", which, ts, secsErr)) errs = append(errs, fmt.Errorf("could not parse span %s time %q as Unix Epoch.Nano: %w", which, ts, usecsErr)) } } } // try RFC3339 then again with nanos t, err := time.Parse(time.RFC3339, ts) if err != nil { // try again with nanos if t, innerErr := time.Parse(time.RFC3339Nano, ts); innerErr == nil { return t, nil } else { errs = append(errs, fmt.Errorf("could not parse span %s time %q as RFC3339 %w", which, ts, err)) errs = append(errs, fmt.Errorf("could not parse span %s time %q as RFC3339Nano %w", which, ts, innerErr)) } } else { return t, nil } errs = append(errs, fmt.Errorf("could not parse span %s time %q as any supported format", which, ts)) return time.Time{}, errors.Join(errs...) } func (c Config) GetEndpoint() *url.URL { ep, _ := c.ParseEndpoint() return ep } // WithEndpoint returns the config with Endpoint set to the provided value. func (c Config) WithEndpoint(with string) Config { c.Endpoint = with return c } // WithTracesEndpoint returns the config with TracesEndpoint set to the provided value. func (c Config) WithTracesEndpoint(with string) Config { c.TracesEndpoint = with return c } // WithProtocol returns the config with protocol set to the provided value. func (c Config) WithProtocol(with string) Config { c.Protocol = with return c } // GetTimeout returns the parsed --timeout value as a time.Duration. func (c Config) GetTimeout() time.Duration { return c.ParseCliTimeout() } // WithTimeout returns the config with Timeout set to the provided value. func (c Config) WithTimeout(with string) Config { c.Timeout = with return c } // GetHeaders returns the stringmap of configured headers. func (c Config) GetHeaders() map[string]string { return c.Headers } // WithHeades returns the config with Heades set to the provided value. func (c Config) WithHeaders(with map[string]string) Config { c.Headers = with return c } // WithInsecure returns the config with Insecure set to the provided value. func (c Config) WithInsecure(with bool) Config { c.Insecure = with return c } // WithBlocking returns the config with Blocking set to the provided value. func (c Config) WithBlocking(with bool) Config { c.Blocking = with return c } // WithTlsNoVerify returns the config with NoTlsVerify set to the provided value. func (c Config) WithTlsNoVerify(with bool) Config { c.TlsNoVerify = with return c } // WithTlsCACert returns the config with TlsCACert set to the provided value. func (c Config) WithTlsCACert(with string) Config { c.TlsCACert = with return c } // WithTlsClientKey returns the config with NoTlsClientKey set to the provided value. func (c Config) WithTlsClientKey(with string) Config { c.TlsClientKey = with return c } // WithTlsClientCert returns the config with NoTlsClientCert set to the provided value. func (c Config) WithTlsClientCert(with string) Config { c.TlsClientCert = with return c } // GetServiceName returns the configured OTel service name. func (c Config) GetServiceName() string { return c.ServiceName } // WithServiceName returns the config with ServiceName set to the provided value. func (c Config) WithServiceName(with string) Config { c.ServiceName = with return c } // WithSpanName returns the config with SpanName set to the provided value. func (c Config) WithSpanName(with string) Config { c.SpanName = with return c } // WithKind returns the config with Kind set to the provided value. func (c Config) WithKind(with string) Config { c.Kind = with return c } // WithAttributes returns the config with Attributes set to the provided value. func (c Config) WithAttributes(with map[string]string) Config { c.Attributes = with return c } // WithStatusCode returns the config with StatusCode set to the provided value. func (c Config) WithStatusCode(with string) Config { c.StatusCode = with return c } // WithStatusDescription returns the config with StatusDescription set to the provided value. func (c Config) WithStatusDescription(with string) Config { c.StatusDescription = with return c } // WithTraceparentCarrierFile returns the config with TraceparentCarrierFile set to the provided value. func (c Config) WithTraceparentCarrierFile(with string) Config { c.TraceparentCarrierFile = with return c } // WithTraceparentIgnoreEnv returns the config with TraceparentIgnoreEnv set to the provided value. func (c Config) WithTraceparentIgnoreEnv(with bool) Config { c.TraceparentIgnoreEnv = with return c } // WithTraceparentPrint returns the config with TraceparentPrint set to the provided value. func (c Config) WithTraceparentPrint(with bool) Config { c.TraceparentPrint = with return c } // WithTraceparentPrintExport returns the config with TraceparentPrintExport set to the provided value. func (c Config) WithTraceparentPrintExport(with bool) Config { c.TraceparentPrintExport = with return c } // WithTraceparentRequired returns the config with TraceparentRequired set to the provided value. func (c Config) WithTraceparentRequired(with bool) Config { c.TraceparentRequired = with return c } // WithBackgroundParentPollMs returns the config with BackgroundParentPollMs set to the provided value. func (c Config) WithBackgroundParentPollMs(with int) Config { c.BackgroundParentPollMs = with return c } // WithBackgroundSockdir returns the config with BackgroundSockdir set to the provided value. func (c Config) WithBackgroundSockdir(with string) Config { c.BackgroundSockdir = with return c } // WithBackgroundWait returns the config with BackgroundWait set to the provided value. func (c Config) WithBackgroundWait(with bool) Config { c.BackgroundWait = with return c } // WithBackgroundSkipParentPidCheck returns the config with BackgroundSkipParentPidCheck set to the provided value. func (c Config) WithBackgroundSkipParentPidCheck(with bool) Config { c.BackgroundSkipParentPidCheck = with return c } // WithStatusCanaryCount returns the config with StatusCanaryCount set to the provided value. func (c Config) WithStatusCanaryCount(with int) Config { c.StatusCanaryCount = with return c } // WithStatusCanaryInterval returns the config with StatusCanaryInterval set to the provided value. func (c Config) WithStatusCanaryInterval(with string) Config { c.StatusCanaryInterval = with return c } // WithSpanStartTime returns the config with SpanStartTime set to the provided value. func (c Config) WithSpanStartTime(with string) Config { c.SpanStartTime = with return c } // WithSpanEndTime returns the config with SpanEndTime set to the provided value. func (c Config) WithSpanEndTime(with string) Config { c.SpanEndTime = with return c } // WithEventName returns the config with EventName set to the provided value. func (c Config) WithEventName(with string) Config { c.EventName = with return c } // WithEventTIme returns the config with EventTIme set to the provided value. func (c Config) WithEventTime(with string) Config { c.EventTime = with return c } // WithCfgFile returns the config with CfgFile set to the provided value. func (c Config) WithCfgFile(with string) Config { c.CfgFile = with return c } // WithVerbose returns the config with Verbose set to the provided value. func (c Config) WithVerbose(with bool) Config { c.Verbose = with return c } // WithFail returns the config with Fail set to the provided value. func (c Config) WithFail(with bool) Config { c.Fail = with return c } // Version returns the program version stored in the config. func (c Config) GetVersion() string { return c.Version } // WithVersion returns the config with Version set to the provided value. func (c Config) WithVersion(with string) Config { c.Version = with return c } ================================================ FILE: otelcli/config_span.go ================================================ package otelcli import ( "encoding/hex" "fmt" "io" "time" "github.com/equinix-labs/otel-cli/otlpclient" "github.com/equinix-labs/otel-cli/w3c/traceparent" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) // NewProtobufSpan creates a new span and populates it with information // from the config struct. func (c Config) NewProtobufSpan() *tracepb.Span { span := otlpclient.NewProtobufSpan() if c.GetIsRecording() { span.TraceId = otlpclient.GenerateTraceId() span.SpanId = otlpclient.GenerateSpanId() } span.Name = c.SpanName span.Kind = otlpclient.SpanKindStringToInt(c.Kind) span.Attributes = otlpclient.StringMapAttrsToProtobuf(c.Attributes) now := time.Now() if c.SpanStartTime != "" { st := c.ParseSpanStartTime() span.StartTimeUnixNano = uint64(st.UnixNano()) } else { span.StartTimeUnixNano = uint64(now.UnixNano()) } if c.SpanEndTime != "" { et := c.ParseSpanEndTime() span.EndTimeUnixNano = uint64(et.UnixNano()) } else { span.EndTimeUnixNano = uint64(now.UnixNano()) } if c.GetIsRecording() { tp := c.LoadTraceparent() if tp.Initialized { span.TraceId = tp.TraceId span.ParentSpanId = tp.SpanId } } else { span.TraceId = otlpclient.GetEmptyTraceId() span.SpanId = otlpclient.GetEmptySpanId() } // --force-trace-id, --force-span-id and --force-parent-span-id let the user set their own trace, span & parent span ids // these work in non-recording mode and will stomp trace id from the traceparent var err error if c.ForceTraceId != "" { span.TraceId, err = parseHex(c.ForceTraceId, 16) c.SoftFailIfErr(err) } if c.ForceSpanId != "" { span.SpanId, err = parseHex(c.ForceSpanId, 8) c.SoftFailIfErr(err) } if c.ForceParentSpanId != "" { span.ParentSpanId, err = parseHex(c.ForceParentSpanId, 8) c.SoftFailIfErr(err) } otlpclient.SetSpanStatus(span, c.StatusCode, c.StatusDescription) return span } // LoadTraceparent follows otel-cli's loading rules, start with envvar then file. // If both are set, the file will override env. // When in non-recording mode, the previous traceparent will be returned if it's // available, otherwise, a zero-valued traceparent is returned. func (c Config) LoadTraceparent() traceparent.Traceparent { tp := traceparent.Traceparent{ Version: 0, TraceId: otlpclient.GetEmptyTraceId(), SpanId: otlpclient.GetEmptySpanId(), Sampling: false, Initialized: true, } if !c.TraceparentIgnoreEnv { var err error tp, err = traceparent.LoadFromEnv() if err != nil { Diag.Error = err.Error() } } if c.TraceparentCarrierFile != "" { fileTp, err := traceparent.LoadFromFile(c.TraceparentCarrierFile) if err != nil { Diag.Error = err.Error() } else if fileTp.Initialized { tp = fileTp } } if c.TraceparentRequired { if tp.Initialized { return tp } else { c.SoftFail("failed to find a valid traceparent carrier in either environment for file '%s' while it's required by --tp-required", c.TraceparentCarrierFile) } } return tp } // PropagateTraceparent saves the traceparent to file if necessary, then prints // span info to the console according to command-line args. func (c Config) PropagateTraceparent(span *tracepb.Span, target io.Writer) { var tp traceparent.Traceparent if c.GetIsRecording() { tp = otlpclient.TraceparentFromProtobufSpan(span, c.GetIsRecording()) } else { // when in non-recording mode, and there is a TP available, propagate that tp = c.LoadTraceparent() } if c.TraceparentCarrierFile != "" { err := tp.SaveToFile(c.TraceparentCarrierFile, c.TraceparentPrintExport) c.SoftFailIfErr(err) } if c.TraceparentPrint { tp.Fprint(target, c.TraceparentPrintExport) } } // parseHex parses hex into a []byte of length provided. Errors if the input is // not valid hex or the converted hex is not the right number of bytes. func parseHex(in string, expectedLen int) ([]byte, error) { out, err := hex.DecodeString(in) if err != nil { return nil, fmt.Errorf("error parsing hex string %q: %w", in, err) } if len(out) != expectedLen { return nil, fmt.Errorf("hex string %q is the wrong length, expected %d bytes but got %d", in, expectedLen, len(out)) } return out, nil } ================================================ FILE: otelcli/config_span_test.go ================================================ package otelcli import ( "bytes" "encoding/hex" "fmt" "os" "testing" "github.com/equinix-labs/otel-cli/otlpclient" ) func TestPropagateTraceparent(t *testing.T) { config := DefaultConfig(). WithTraceparentCarrierFile(""). WithTraceparentPrint(false). WithTraceparentPrintExport(false) tp := "00-3433d5ae39bdfee397f44be5146867b3-8a5518f1e5c54d0a-01" tid := "3433d5ae39bdfee397f44be5146867b3" sid := "8a5518f1e5c54d0a" os.Setenv("TRACEPARENT", tp) span := otlpclient.NewProtobufSpan() span.TraceId, _ = hex.DecodeString(tid) span.SpanId, _ = hex.DecodeString(sid) buf := new(bytes.Buffer) config.PropagateTraceparent(span, buf) if buf.Len() != 0 { t.Errorf("nothing was supposed to be written but %d bytes were", buf.Len()) } config.TraceparentPrint = true config.TraceparentPrintExport = true buf = new(bytes.Buffer) config.PropagateTraceparent(span, buf) if buf.Len() == 0 { t.Error("expected more than zero bytes but got none") } expected := fmt.Sprintf("# trace id: %s\n# span id: %s\nexport TRACEPARENT=%s\n", tid, sid, tp) if buf.String() != expected { t.Errorf("got unexpected output, expected '%s', got '%s'", expected, buf.String()) } } func TestNewProtobufSpanWithConfig(t *testing.T) { c := DefaultConfig().WithSpanName("test span 123") span := c.NewProtobufSpan() if span.Name != "test span 123" { t.Error("span event attributes must not be nil") } } ================================================ FILE: otelcli/config_test.go ================================================ package otelcli import ( "testing" "time" "github.com/google/go-cmp/cmp" ) func TestConfig_ToStringMap(t *testing.T) { c := Config{} c.Headers = map[string]string{ "123test": "deadbeefcafe", } fsm := c.ToStringMap() if _, ok := fsm["headers"]; !ok { t.Errorf("missing key 'headers' in returned string map: %q", fsm) t.Fail() } if fsm["headers"] != "123test=deadbeefcafe" { t.Errorf("expected header value not found in flattened string map: %q", fsm) t.Fail() } } func TestIsRecording(t *testing.T) { c := DefaultConfig() if c.GetIsRecording() { t.Fail() } c.Endpoint = "https://localhost:4318" if !c.GetIsRecording() { t.Fail() } } func TestFlattenStringMap(t *testing.T) { in := map[string]string{ "sample1": "value1", "more": "stuff", "getting": "bored", "okay": "that's enough", } out := flattenStringMap(in, "{}") if out != "getting=bored,more=stuff,okay=that's enough,sample1=value1" { t.Fail() } } func TestParseCkvStringMap(t *testing.T) { expect := map[string]string{ "sample1": "value1", "more": "stuff", "getting": "bored", "okay": "that's enough", "1": "324", } got, err := parseCkvStringMap("1=324,getting=bored,more=stuff,okay=that's enough,sample1=value1") if err != nil { t.Errorf("error on valid input: %s", err) } if diff := cmp.Diff(expect, got); diff != "" { t.Errorf("maps didn't match (-want +got):\n%s", diff) } } func TestParseTime(t *testing.T) { mustParse := func(layout, value string) time.Time { out, err := time.Parse(layout, value) if err != nil { t.Fatalf("failed to parse time '%s' as format '%s': %s", value, layout, err) } return out } for _, testcase := range []struct { name string input string want time.Time }{ { name: "Unix epoch time without nanoseconds", input: "1617739561", // date +%s want: time.Unix(1617739561, 0), }, { name: "Unix epoch time with nanoseconds", input: "1617739615.759793032", // date +%s.%N want: time.Unix(1617739615, 759793032), }, { name: "RFC3339", input: "2021-04-06T13:07:54Z", want: mustParse(time.RFC3339, "2021-04-06T13:07:54Z"), }, { name: "RFC3339 with nanoseconds", input: "2021-04-06T13:12:40.792426395Z", want: mustParse(time.RFC3339Nano, "2021-04-06T13:12:40.792426395Z"), }, // date(1) RFC3339 format is incompatible with Go's formats // so parseTime takes care of that automatically { name: "date(1) RFC3339 output, with timezone", input: "2021-04-06 13:07:54-07:00", //date --rfc-3339=seconds want: mustParse(time.RFC3339, "2021-04-06T13:07:54-07:00"), }, { name: "date(1) RFC3339 with nanoseconds and timezone", input: "2021-04-06 13:12:40.792426395-07:00", // date --rfc-3339=ns want: mustParse(time.RFC3339Nano, "2021-04-06T13:12:40.792426395-07:00"), }, // TODO: maybe refactor parseTime to make failures easier to validate? // @tobert: gonna leave that for functional tests for now } { t.Run(testcase.name, func(t *testing.T) { out, _ := DefaultConfig().parseTime(testcase.input, "test") if !out.Equal(testcase.want) { t.Errorf("got wrong time from parseTime: %s", out.Format(time.RFC3339Nano)) } }) } } func TestParseCliTime(t *testing.T) { for _, testcase := range []struct { name string input string expected time.Duration }{ // otel-cli will still timeout but it will be the default timeouts for // each component { name: "empty string returns 0 duration", input: "", expected: time.Duration(0), }, { name: "0 returns 0 duration", input: "0", expected: time.Duration(0), }, { name: "1s returns 1 second", input: "1s", expected: time.Second, }, { name: "100ms returns 100 milliseconds", input: "100ms", expected: time.Millisecond * 100, }, } { t.Run(testcase.name, func(t *testing.T) { config := DefaultConfig().WithTimeout(testcase.input) got := config.ParseCliTimeout() if got != testcase.expected { ed := testcase.expected.String() gd := got.String() t.Errorf("duration string %q was expected to return %s but returned %s", config.Timeout, ed, gd) } }) } } func TestParseEndpoint(t *testing.T) { // func parseEndpoint(config Config) (*url.URL, string) { for _, tc := range []struct { config Config wantEndpoint string wantSource string }{ // gRPC, general, bare host { config: DefaultConfig().WithEndpoint("localhost"), wantEndpoint: "grpc://localhost:4317", wantSource: "general", }, // gRPC, general, should be bare host:port { config: DefaultConfig().WithEndpoint("localhost:4317"), wantEndpoint: "grpc://localhost:4317", wantSource: "general", }, // gRPC, general, https URL, should transform to host:port { config: DefaultConfig().WithEndpoint("https://localhost:4317").WithProtocol("grpc"), wantEndpoint: "https://localhost:4317/v1/traces", wantSource: "general", }, // HTTP, general, with a provided default signal path, should not be modified { config: DefaultConfig().WithEndpoint("http://localhost:9999/v1/traces"), wantEndpoint: "http://localhost:9999/v1/traces", wantSource: "general", }, // HTTP, general, with a provided custom signal path, signal path should get appended { config: DefaultConfig().WithEndpoint("http://localhost:9999/my/collector/path"), wantEndpoint: "http://localhost:9999/my/collector/path/v1/traces", wantSource: "general", }, // HTTPS, general, without path, should get /v1/traces appended { config: DefaultConfig().WithEndpoint("https://localhost:4317"), wantEndpoint: "https://localhost:4317/v1/traces", wantSource: "general", }, // gRPC, signal, should come through with just the grpc:// added { config: DefaultConfig().WithTracesEndpoint("localhost"), wantEndpoint: "grpc://localhost:4317", wantSource: "signal", }, // http, signal, should come through unmodified { config: DefaultConfig().WithTracesEndpoint("http://localhost"), wantEndpoint: "http://localhost", wantSource: "signal", }, } { u, src := tc.config.ParseEndpoint() if u.String() != tc.wantEndpoint { t.Errorf("Expected endpoint %q but got %q", tc.wantEndpoint, u.String()) } if src != tc.wantSource { t.Errorf("Expected source %q for test url %q but got %q", tc.wantSource, u.String(), src) } } } func TestWithEndpoint(t *testing.T) { if DefaultConfig().WithEndpoint("foobar").Endpoint != "foobar" { t.Fail() } } func TestWithTracesEndpoint(t *testing.T) { if DefaultConfig().WithTracesEndpoint("foobar").TracesEndpoint != "foobar" { t.Fail() } } func TestWithTimeout(t *testing.T) { if DefaultConfig().WithTimeout("foobar").Timeout != "foobar" { t.Fail() } } func TestWithHeaders(t *testing.T) { attr := map[string]string{"foo": "bar"} c := DefaultConfig().WithHeaders(attr) if diff := cmp.Diff(attr, c.Headers); diff != "" { t.Errorf("Headers did not match (-want +got):\n%s", diff) } } func TestWithInsecure(t *testing.T) { if DefaultConfig().WithInsecure(true).Insecure != true { t.Fail() } } func TestWithBlocking(t *testing.T) { if DefaultConfig().WithBlocking(true).Blocking != true { t.Fail() } } func TestWithTlsNoVerify(t *testing.T) { if DefaultConfig().WithTlsNoVerify(true).TlsNoVerify != true { t.Fail() } } func TestWithTlsCACert(t *testing.T) { if DefaultConfig().WithTlsCACert("/a/b/c").TlsCACert != "/a/b/c" { t.Fail() } } func TestWithTlsClientKey(t *testing.T) { if DefaultConfig().WithTlsClientKey("/c/b/a").TlsClientKey != "/c/b/a" { t.Fail() } } func TestWithTlsClientCert(t *testing.T) { if DefaultConfig().WithTlsClientCert("/b/c/a").TlsClientCert != "/b/c/a" { t.Fail() } } func TestWithServiceName(t *testing.T) { if DefaultConfig().WithServiceName("foobar").ServiceName != "foobar" { t.Fail() } } func TestWithSpanName(t *testing.T) { if DefaultConfig().WithSpanName("foobar").SpanName != "foobar" { t.Fail() } } func TestWithKind(t *testing.T) { if DefaultConfig().WithKind("producer").Kind != "producer" { t.Fail() } } func TestWithAttributes(t *testing.T) { attr := map[string]string{"foo": "bar"} c := DefaultConfig().WithAttributes(attr) if diff := cmp.Diff(attr, c.Attributes); diff != "" { t.Errorf("Attributes did not match (-want +got):\n%s", diff) } } func TestWithStatusCode(t *testing.T) { if diff := cmp.Diff(DefaultConfig().WithStatusCode("unset").StatusCode, "unset"); diff != "" { t.Fatalf("mismatch (-want +got):\n%s", diff) } if diff := cmp.Diff(DefaultConfig().WithStatusCode("ok").StatusCode, "ok"); diff != "" { t.Fatalf("mismatch (-want +got):\n%s", diff) } if diff := cmp.Diff(DefaultConfig().WithStatusCode("error").StatusCode, "error"); diff != "" { t.Fatalf("mismatch (-want +got):\n%s", diff) } } func TestWithStatusDescription(t *testing.T) { if diff := cmp.Diff(DefaultConfig().WithStatusDescription("Set SCE To AUX").StatusDescription, "Set SCE To AUX"); diff != "" { t.Fatalf("mismatch (-want +got):\n%s", diff) } } func TestWithTraceparentCarrierFile(t *testing.T) { if DefaultConfig().WithTraceparentCarrierFile("foobar").TraceparentCarrierFile != "foobar" { t.Fail() } } func TestWithTraceparentIgnoreEnv(t *testing.T) { if DefaultConfig().WithTraceparentIgnoreEnv(true).TraceparentIgnoreEnv != true { t.Fail() } } func TestWithTraceparentPrint(t *testing.T) { if DefaultConfig().WithTraceparentPrint(true).TraceparentPrint != true { t.Fail() } } func TestWithTraceparentPrintExport(t *testing.T) { if DefaultConfig().WithTraceparentPrintExport(true).TraceparentPrintExport != true { t.Fail() } } func TestWithTraceparentRequired(t *testing.T) { if DefaultConfig().WithTraceparentRequired(true).TraceparentRequired != true { t.Fail() } } func TestWithBackgroundParentPollMs(t *testing.T) { if DefaultConfig().WithBackgroundParentPollMs(1111).BackgroundParentPollMs != 1111 { t.Fail() } } func TestWithBackgroundSockdir(t *testing.T) { if DefaultConfig().WithBackgroundSockdir("foobar").BackgroundSockdir != "foobar" { t.Fail() } } func TestWithBackgroundWait(t *testing.T) { if DefaultConfig().WithBackgroundWait(true).BackgroundWait != true { t.Fail() } } func TestWithStatusCanaryCount(t *testing.T) { if DefaultConfig().WithStatusCanaryCount(1337).StatusCanaryCount != 1337 { t.Fail() } } func TestWithStatusCanaryInterval(t *testing.T) { if DefaultConfig().WithStatusCanaryInterval("1337ms").StatusCanaryInterval != "1337ms" { t.Fail() } } func TestWithSpanStartTime(t *testing.T) { if DefaultConfig().WithSpanStartTime("foobar").SpanStartTime != "foobar" { t.Fail() } } func TestWithSpanEndTime(t *testing.T) { if DefaultConfig().WithSpanEndTime("foobar").SpanEndTime != "foobar" { t.Fail() } } func TestWithEventName(t *testing.T) { if DefaultConfig().WithEventName("foobar").EventName != "foobar" { t.Fail() } } func TestWithEventTime(t *testing.T) { if DefaultConfig().WithEventTime("foobar").EventTime != "foobar" { t.Fail() } } func TestWithCfgFile(t *testing.T) { if DefaultConfig().WithCfgFile("foobar").CfgFile != "foobar" { t.Fail() } } func TestWithVerbose(t *testing.T) { if DefaultConfig().WithVerbose(true).Verbose != true { t.Fail() } } ================================================ FILE: otelcli/config_tls.go ================================================ package otelcli import ( "crypto/tls" "crypto/x509" "fmt" "net" "net/url" "os" ) // TlsConfig evaluates otel-cli configuration and returns a tls.Config // that can be used by grpc or https. func (config Config) GetTlsConfig() *tls.Config { tlsConfig := &tls.Config{} if config.TlsNoVerify { Diag.InsecureSkipVerify = true tlsConfig.InsecureSkipVerify = true } // puts the provided CA certificate into the root pool // when not provided, Go TLS will automatically load the system CA pool if config.TlsCACert != "" { data, err := os.ReadFile(config.TlsCACert) if err != nil { config.SoftFail("failed to load CA certificate: %s", err) } certpool := x509.NewCertPool() certpool.AppendCertsFromPEM(data) tlsConfig.RootCAs = certpool } // client certificate authentication if config.TlsClientCert != "" && config.TlsClientKey != "" { clientPEM, err := os.ReadFile(config.TlsClientCert) if err != nil { config.SoftFail("failed to read client certificate file %s: %s", config.TlsClientCert, err) } clientKeyPEM, err := os.ReadFile(config.TlsClientKey) if err != nil { config.SoftFail("failed to read client key file %s: %s", config.TlsClientKey, err) } certPair, err := tls.X509KeyPair(clientPEM, clientKeyPEM) if err != nil { config.SoftFail("failed to parse client cert pair: %s", err) } tlsConfig.Certificates = []tls.Certificate{certPair} } else if config.TlsClientCert != "" { config.SoftFail("client cert and key must be specified together") } else if config.TlsClientKey != "" { config.SoftFail("client cert and key must be specified together") } return tlsConfig } // GetInsecure returns true if the configuration expects a non-TLS connection. func (c Config) GetInsecure() bool { endpointURL := c.GetEndpoint() isLoopback, err := isLoopbackAddr(endpointURL) c.SoftFailIfErr(err) // Go's TLS does the right thing and forces us to say we want to disable encryption, // but I expect most users of this program to point at a localhost endpoint that might not // have any encryption available, or setting it up raises the bar of entry too high. // The compromise is to automatically flip this flag to true when endpoint contains an // an obvious "localhost", "127.0.0.x", or "::1" address. if c.Insecure || (isLoopback && endpointURL.Scheme != "https") { return true } else if endpointURL.Scheme == "http" || endpointURL.Scheme == "unix" { return true } return false } // isLoopbackAddr takes a url.URL, looks up the address, then returns true // if it points at either a v4 or v6 loopback address. // As I understood the OTLP spec, only host:port or an HTTP URL are acceptable. // This function is _not_ meant to validate the endpoint, that will happen when // otel-go attempts to connect to the endpoint. func isLoopbackAddr(u *url.URL) (bool, error) { hostname := u.Hostname() if hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" { Diag.DetectedLocalhost = true return true, nil } ips, err := net.LookupIP(hostname) if err != nil { return false, fmt.Errorf("unable to look up hostname '%s': %s", hostname, err) } // all ips returned must be loopback to return true // cases where that isn't true should be super rare, and probably all shenanigans allAreLoopback := true for _, ip := range ips { if !ip.IsLoopback() { allAreLoopback = false } } Diag.DetectedLocalhost = allAreLoopback return allAreLoopback, nil } ================================================ FILE: otelcli/diagnostics.go ================================================ package otelcli import ( "strconv" "strings" ) // package global Diagnostics handle, written to from all over otel-cli // and used in e.g. otel-cli status to show internal state var Diag Diagnostics // Diagnostics is a place to put things that are useful for testing and // diagnosing issues with otel-cli. The only user-facing feature that should be // using these is otel-cli status. type Diagnostics struct { CliArgs []string `json:"cli_args"` IsRecording bool `json:"is_recording"` ConfigFileLoaded bool `json:"config_file_loaded"` NumArgs int `json:"number_of_args"` DetectedLocalhost bool `json:"detected_localhost"` InsecureSkipVerify bool `json:"insecure_skip_verify"` ParsedTimeoutMs int64 `json:"parsed_timeout_ms"` Endpoint string `json:"endpoint"` // the computed endpoint, not the raw config val EndpointSource string `json:"endpoint_source"` Error string `json:"error"` ExecExitCode int `json:"exec_exit_code"` Retries int `json:"retries"` } // ToMap returns the Diag struct as a string map for testing. func (d *Diagnostics) ToStringMap() map[string]string { return map[string]string{ "cli_args": strings.Join(d.CliArgs, " "), "is_recording": strconv.FormatBool(d.IsRecording), "config_file_loaded": strconv.FormatBool(d.ConfigFileLoaded), "number_of_args": strconv.Itoa(d.NumArgs), "detected_localhost": strconv.FormatBool(d.DetectedLocalhost), "parsed_timeout_ms": strconv.FormatInt(d.ParsedTimeoutMs, 10), "endpoint": d.Endpoint, "endpoint_source": d.EndpointSource, "error": d.Error, } } // SetError sets the diagnostics Error to the error's string if it's // not nil and returns the same error so it can be inlined in return. func (d *Diagnostics) SetError(err error) error { if err != nil { Diag.Error = err.Error() } return err } // GetExitCode() is a helper for Cobra to retrieve the exit code, mainly // used by exec to make otel-cli return the child program's exit code. func GetExitCode() int { return Diag.ExecExitCode } ================================================ FILE: otelcli/exec.go ================================================ package otelcli import ( "context" "fmt" "os" "os/exec" "os/signal" "os/user" "strings" "time" "github.com/equinix-labs/otel-cli/otlpclient" "github.com/equinix-labs/otel-cli/w3c/traceparent" "github.com/spf13/cobra" commonpb "go.opentelemetry.io/proto/otlp/common/v1" tracev1 "go.opentelemetry.io/proto/otlp/trace/v1" ) // execCmd sets up the `otel-cli exec` command func execCmd(config *Config) *cobra.Command { cmd := cobra.Command{ Use: "exec", Short: "execute the command provided", Long: `execute the command provided after the subcommand inside a span, measuring and reporting how long it took to run. The wrapping span's w3c traceparent is automatically passed to the child process's environment as TRACEPARENT. Examples: otel-cli exec -n my-cool-thing -s interesting-step curl https://cool-service/api/v1/endpoint otel-cli exec -s "outer span" -- otel-cli exec -s "inner span" sleep 1`, Run: doExec, Args: cobra.MinimumNArgs(1), } addCommonParams(&cmd, config) addSpanParams(&cmd, config) addAttrParams(&cmd, config) addClientParams(&cmd, config) defaults := DefaultConfig() cmd.Flags().StringVar( &config.ExecCommandTimeout, "command-timeout", defaults.ExecCommandTimeout, "timeout for the child process, when 0 otel-cli will wait forever", ) cmd.Flags().BoolVar( &config.ExecTpDisableInject, "tp-disable-inject", defaults.ExecTpDisableInject, "disable automatically replacing {{traceparent}} with a traceparent", ) return &cmd } func doExec(cmd *cobra.Command, args []string) { ctx := cmd.Context() config := getConfig(ctx) span := config.NewProtobufSpan() processAttrs := processArgAttrs(args) // might be overwritten in process setup // no deadline if there is no command timeout set cancelCtxDeadline := func() {} // fork the context for the command so its deadline doesn't impact the otlpclient ctx cmdCtx := ctx cmdTimeout := config.ParseExecCommandTimeout() if cmdTimeout > 0 { cmdCtx, cancelCtxDeadline = context.WithDeadline(ctx, time.Now().Add(cmdTimeout)) } // pass the existing env but add the latest TRACEPARENT carrier so e.g. // otel-cli exec 'otel-cli exec sleep 1' will relate the spans automatically childEnv := []string{} // set the traceparent to the current span to be available to the child process var tp traceparent.Traceparent if config.GetIsRecording() { tp = otlpclient.TraceparentFromProtobufSpan(span, config.GetIsRecording()) childEnv = append(childEnv, fmt.Sprintf("TRACEPARENT=%s", tp.Encode())) // when not recording, and a traceparent is available, pass it through } else if !config.TraceparentIgnoreEnv { tp := config.LoadTraceparent() if tp.Initialized { childEnv = append(childEnv, fmt.Sprintf("TRACEPARENT=%s", tp.Encode())) } } var child *exec.Cmd if len(args) > 1 { tpArgs := make([]string, len(args)-1) if config.ExecTpDisableInject { copy(tpArgs, args[1:]) } else { // loop over the args replacing {{traceparent}} with the current tp for i, arg := range args[1:] { tpArgs[i] = strings.Replace(arg, "{{traceparent}}", tp.Encode(), -1) } // overwrite process args attributes with the injected values processAttrs = processArgAttrs(append([]string{args[0]}, tpArgs...)) } child = exec.CommandContext(cmdCtx, args[0], tpArgs...) } else { child = exec.CommandContext(cmdCtx, args[0]) } // attach all stdio to the parent's handles child.Stdin = os.Stdin child.Stdout = os.Stdout child.Stderr = os.Stderr // grab everything BUT the TRACEPARENT envvar for _, env := range os.Environ() { if !strings.HasPrefix(env, "TRACEPARENT=") { childEnv = append(childEnv, env) } } child.Env = childEnv // ctrl-c (sigint) is forwarded to the child process signals := make(chan os.Signal, 10) signalsDone := make(chan struct{}) signal.Notify(signals, os.Interrupt) go func() { sig := <-signals child.Process.Signal(sig) // this might not seem necessary but without it, otel-cli exits before sending the span close(signalsDone) }() span.StartTimeUnixNano = uint64(time.Now().UnixNano()) if err := child.Run(); err != nil { span.Status = &tracev1.Status{ Message: fmt.Sprintf("exec command failed: %s", err), Code: tracev1.Status_STATUS_CODE_ERROR, } } span.EndTimeUnixNano = uint64(time.Now().UnixNano()) // append process attributes span.Attributes = append(span.Attributes, processAttrs...) pidAttrs := processPidAttrs(config, int64(child.Process.Pid), int64(os.Getpid())) span.Attributes = append(span.Attributes, pidAttrs...) cancelCtxDeadline() close(signals) <-signalsDone // set --timeout on just the OTLP egress, starting now instead of process start time ctx, cancelCtxDeadline = context.WithDeadline(ctx, time.Now().Add(config.GetTimeout())) defer cancelCtxDeadline() ctx, client := StartClient(ctx, config) ctx, err := otlpclient.SendSpan(ctx, client, config, span) if err != nil { config.SoftFail("unable to send span: %s", err) } _, err = client.Stop(ctx) if err != nil { config.SoftFail("client.Stop() failed: %s", err) } // set the global exit code so main() can grab it and os.Exit() properly Diag.ExecExitCode = child.ProcessState.ExitCode() config.PropagateTraceparent(span, os.Stdout) } // processArgAttrs turns the provided args list into OTel attributes // that can be appended to a protobuf span's span.Attributes. // https://opentelemetry.io/docs/specs/semconv/attributes-registry/process/ func processArgAttrs(args []string) []*commonpb.KeyValue { // convert args to an OpenTelemetry string list avlist := make([]*commonpb.AnyValue, len(args)) for i, v := range args { sv := commonpb.AnyValue_StringValue{StringValue: v} av := commonpb.AnyValue{Value: &sv} avlist[i] = &av } return []*commonpb.KeyValue{ { Key: "process.command", Value: &commonpb.AnyValue{ Value: &commonpb.AnyValue_StringValue{StringValue: args[0]}, }, }, { Key: "process.command_args", Value: &commonpb.AnyValue{ Value: &commonpb.AnyValue_ArrayValue{ ArrayValue: &commonpb.ArrayValue{ Values: avlist, }, }, }, }, } } // processPidAttrs returns process.{owner,pid,parent_pid} attributes ready // to append to a protobuf span's span.Attributes. func processPidAttrs(config Config, ppid, pid int64) []*commonpb.KeyValue { user, err := user.Current() config.SoftLogIfErr(err) return []*commonpb.KeyValue{ { Key: "process.owner", Value: &commonpb.AnyValue{ Value: &commonpb.AnyValue_StringValue{StringValue: user.Username}, }, }, { Key: "process.pid", Value: &commonpb.AnyValue{ Value: &commonpb.AnyValue_IntValue{IntValue: pid}, }, }, { Key: "process.parent_pid", Value: &commonpb.AnyValue{ Value: &commonpb.AnyValue_IntValue{IntValue: ppid}, }, }, } } ================================================ FILE: otelcli/otlpclient.go ================================================ package otelcli import ( "context" "fmt" "strings" "github.com/equinix-labs/otel-cli/otlpclient" ) // StartClient uses the Config to setup and start either a gRPC or HTTP client, // and returns the OTLPClient interface to them. func StartClient(ctx context.Context, config Config) (context.Context, otlpclient.OTLPClient) { if !config.GetIsRecording() { return ctx, otlpclient.NewNullClient(config) } if config.Protocol != "" && config.Protocol != "grpc" && config.Protocol != "http/protobuf" { err := fmt.Errorf("invalid protocol setting %q", config.Protocol) Diag.Error = err.Error() config.SoftFail(err.Error()) } endpointURL := config.GetEndpoint() var client otlpclient.OTLPClient if config.Protocol != "grpc" && (strings.HasPrefix(config.Protocol, "http/") || endpointURL.Scheme == "http" || endpointURL.Scheme == "https") { client = otlpclient.NewHttpClient(config) } else { client = otlpclient.NewGrpcClient(config) } ctx, err := client.Start(ctx) if err != nil { Diag.Error = err.Error() config.SoftFail("Failed to start OTLP client: %s", err) } return ctx, client } ================================================ FILE: otelcli/root.go ================================================ // Package otelcli implements the otel-cli subcommands and argument parsing // with Cobra and implements functionality using otlpclient and otlpserver. package otelcli import ( "context" "os" "github.com/spf13/cobra" ) // cliContextKey is a type for storing an Config in context. type cliContextKey string // configContextKey returns the typed key for storing/retrieving config in context. func configContextKey() cliContextKey { return cliContextKey("config") } // getConfigRef retrieves the otelcli.Config from the context and returns a // pointer to it. func getConfigRef(ctx context.Context) *Config { if cv := ctx.Value(configContextKey()); cv != nil { if c, ok := cv.(*Config); ok { return c } else { panic("BUG: failed to unwrap config that was in context, please report an issue") } } else { panic("BUG: failed to retrieve config from context, please report an issue") } } // getConfig retrieves the otelcli.Config from context and returns a copy. func getConfig(ctx context.Context) Config { config := getConfigRef(ctx) return *config } // createRootCmd builds up the Cobra command-line, calling through to subcommand // builder funcs to build the whole tree. func createRootCmd(config *Config) *cobra.Command { // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "otel-cli", Short: "CLI for creating and sending OpenTelemetry spans and events.", Long: `A command-line interface for generating OpenTelemetry data on the command line.`, PersistentPreRun: func(cmd *cobra.Command, args []string) { config := getConfigRef(cmd.Context()) if err := config.LoadFile(); err != nil { config.SoftFail("Error while loading configuration file %s: %s", config.CfgFile, err) } if err := config.LoadEnv(os.Getenv); err != nil { // will need to specify --fail --verbose flags to see these errors config.SoftFail("Error while loading environment variables: %s", err) } }, } cobra.EnableCommandSorting = false rootCmd.Flags().SortFlags = false Diag.NumArgs = len(os.Args) - 1 Diag.CliArgs = []string{} if len(os.Args) > 1 { Diag.CliArgs = os.Args[1:] } // add all the subcommands to rootCmd rootCmd.AddCommand(spanCmd(config)) rootCmd.AddCommand(execCmd(config)) rootCmd.AddCommand(statusCmd(config)) rootCmd.AddCommand(serverCmd(config)) rootCmd.AddCommand(versionCmd(config)) rootCmd.AddCommand(completionCmd(config)) return rootCmd } // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once. func Execute(version string) { config := DefaultConfig() config.Version = version // Cobra can tunnel config through context, so set that up now ctx := context.WithValue(context.Background(), configContextKey(), &config) rootCmd := createRootCmd(&config) cobra.CheckErr(rootCmd.ExecuteContext(ctx)) } // addCommonParams adds the --config and --endpoint params to the command. func addCommonParams(cmd *cobra.Command, config *Config) { defaults := DefaultConfig() // --config / -c a JSON configuration file cmd.Flags().StringVarP(&config.CfgFile, "config", "c", defaults.CfgFile, "JSON configuration file") // --endpoint an endpoint to send otlp output to cmd.Flags().StringVar(&config.Endpoint, "endpoint", defaults.Endpoint, "host and port for the desired OTLP/gRPC or OTLP/HTTP endpoint (use http:// or https:// for OTLP/HTTP)") // --traces-endpoint sets the endpoint for the traces signal cmd.Flags().StringVar(&config.TracesEndpoint, "traces-endpoint", defaults.TracesEndpoint, "HTTP(s) URL for traces") // --protocol allows setting the OTLP protocol instead of relying on auto-detection from URI cmd.Flags().StringVar(&config.Protocol, "protocol", defaults.Protocol, "desired OTLP protocol: grpc or http/protobuf") // --timeout a default timeout to use in all otel-cli operations (default 1s) cmd.Flags().StringVar(&config.Timeout, "timeout", defaults.Timeout, "timeout for otel-cli operations, all timeouts in otel-cli use this value") // --verbose tells otel-cli to actually log errors to stderr instead of failing silently cmd.Flags().BoolVar(&config.Verbose, "verbose", defaults.Verbose, "print errors on failure instead of always being silent") // --fail causes a non-zero exit status on error cmd.Flags().BoolVar(&config.Fail, "fail", defaults.Fail, "on failure, exit with a non-zero status") } // addClientParams adds the common CLI flags for e.g. span and exec to the command. // envvars are named according to the otel specs, others use the OTEL_CLI prefix // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/sdk-environment-variables.md // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md func addClientParams(cmd *cobra.Command, config *Config) { defaults := DefaultConfig() config.Headers = make(map[string]string) // OTEL_EXPORTER standard env and variable params cmd.Flags().StringToStringVar(&config.Headers, "otlp-headers", defaults.Headers, "a comma-sparated list of key=value headers to send on OTLP connection") // DEPRECATED // TODO: remove before 1.0 cmd.Flags().BoolVar(&config.Blocking, "otlp-blocking", defaults.Blocking, "DEPRECATED: does nothing, please file an issue if you need this.") cmd.Flags().BoolVar(&config.Insecure, "insecure", defaults.Insecure, "allow connecting to cleartext endpoints") cmd.Flags().StringVar(&config.TlsCACert, "tls-ca-cert", defaults.TlsCACert, "a file containing the certificate authority bundle") cmd.Flags().StringVar(&config.TlsClientCert, "tls-client-cert", defaults.TlsClientCert, "a file containing the client certificate") cmd.Flags().StringVar(&config.TlsClientKey, "tls-client-key", defaults.TlsClientKey, "a file containing the client certificate key") cmd.Flags().BoolVar(&config.TlsNoVerify, "tls-no-verify", defaults.TlsNoVerify, "insecure! disables verification of the server certificate and name, mostly for self-signed CAs") // --no-tls-verify is deprecated, will remove before 1.0 cmd.Flags().BoolVar(&config.TlsNoVerify, "no-tls-verify", defaults.TlsNoVerify, "(deprecated) same as --tls-no-verify") // OTEL_CLI trace propagation options cmd.Flags().BoolVar(&config.TraceparentRequired, "tp-required", defaults.TraceparentRequired, "when set to true, fail and log if a traceparent can't be picked up from TRACEPARENT ennvar or a carrier file") cmd.Flags().StringVar(&config.TraceparentCarrierFile, "tp-carrier", defaults.TraceparentCarrierFile, "a file for reading and WRITING traceparent across invocations") cmd.Flags().BoolVar(&config.TraceparentIgnoreEnv, "tp-ignore-env", defaults.TraceparentIgnoreEnv, "ignore the TRACEPARENT envvar even if it's set") cmd.Flags().BoolVar(&config.TraceparentPrint, "tp-print", defaults.TraceparentPrint, "print the trace id, span id, and the w3c-formatted traceparent representation of the new span") cmd.Flags().BoolVarP(&config.TraceparentPrintExport, "tp-export", "p", defaults.TraceparentPrintExport, "same as --tp-print but it puts an 'export ' in front so it's more convinenient to source in scripts") } func addSpanParams(cmd *cobra.Command, config *Config) { defaults := DefaultConfig() // --name / -s cmd.Flags().StringVarP(&config.SpanName, "name", "n", defaults.SpanName, "set the name of the span") // --service / -n cmd.Flags().StringVarP(&config.ServiceName, "service", "s", defaults.ServiceName, "set the name of the application sent on the traces") // --kind / -k cmd.Flags().StringVarP(&config.Kind, "kind", "k", defaults.Kind, "set the trace kind, e.g. internal, server, client, producer, consumer") // expert options: --force-trace-id, --force-span-id, --force-parent-span-id allow setting custom trace, span and parent span ids cmd.Flags().StringVar(&config.ForceTraceId, "force-trace-id", defaults.ForceTraceId, "expert: force the trace id to be the one provided in hex") cmd.Flags().StringVar(&config.ForceSpanId, "force-span-id", defaults.ForceSpanId, "expert: force the span id to be the one provided in hex") cmd.Flags().StringVar(&config.ForceParentSpanId, "force-parent-span-id", defaults.ForceParentSpanId, "expert: force the parent span id to be the one provided in hex") addSpanStatusParams(cmd, config) } func addSpanStartEndParams(cmd *cobra.Command, config *Config) { defaults := DefaultConfig() // --start $timestamp (RFC3339 or Unix_Epoch.Nanos) cmd.Flags().StringVar(&config.SpanStartTime, "start", defaults.SpanStartTime, "a Unix epoch or RFC3339 timestamp for the start of the span") // --end $timestamp cmd.Flags().StringVar(&config.SpanEndTime, "end", defaults.SpanEndTime, "an Unix epoch or RFC3339 timestamp for the end of the span") } func addSpanStatusParams(cmd *cobra.Command, config *Config) { defaults := DefaultConfig() // --status-code / -sc cmd.Flags().StringVar(&config.StatusCode, "status-code", defaults.StatusCode, "set the span status code, e.g. unset|ok|error") // --status-description / -sd cmd.Flags().StringVar(&config.StatusDescription, "status-description", defaults.StatusDescription, "set the span status description when a span status code of error is set, e.g. 'cancelled'") } func addAttrParams(cmd *cobra.Command, config *Config) { defaults := DefaultConfig() // --attrs key=value,foo=bar config.Attributes = make(map[string]string) cmd.Flags().StringToStringVarP(&config.Attributes, "attrs", "a", defaults.Attributes, "a comma-separated list of key=value attributes") } ================================================ FILE: otelcli/server.go ================================================ package otelcli import ( "strings" "github.com/equinix-labs/otel-cli/otlpserver" "github.com/spf13/cobra" ) const defaultOtlpEndpoint = "grpc://localhost:4317" const spanBgSockfilename = "otel-cli-background.sock" func serverCmd(config *Config) *cobra.Command { cmd := cobra.Command{ Use: "server", Short: "run an embedded OTLP server", Long: "Run otel-cli as an OTLP server. See subcommands.", } cmd.AddCommand(serverJsonCmd(config)) cmd.AddCommand(serverTuiCmd(config)) return &cmd } // runServer runs the server on either grpc or http and blocks until the server // stops or is killed. func runServer(config Config, cb otlpserver.Callback, stop otlpserver.Stopper) { // unlike the rest of otel-cli, server should default to localhost:4317 if config.Endpoint == "" { config.Endpoint = defaultOtlpEndpoint } endpointURL, _ := config.ParseEndpoint() var cs otlpserver.OtlpServer if config.Protocol != "grpc" && (strings.HasPrefix(config.Protocol, "http/") || endpointURL.Scheme == "http") { cs = otlpserver.NewServer("http", cb, stop) } else if config.Protocol == "https" || endpointURL.Scheme == "https" { config.SoftFail("https server is not supported yet, please raise an issue") } else { cs = otlpserver.NewServer("grpc", cb, stop) } defer cs.Stop() cs.ListenAndServe(endpointURL.Host) } ================================================ FILE: otelcli/server_json.go ================================================ package otelcli import ( "context" "encoding/hex" "encoding/json" "log" "os" "path/filepath" "strconv" "time" "github.com/equinix-labs/otel-cli/otlpserver" "github.com/spf13/cobra" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) // jsonSvr holds the command-line configured settings for otel-cli server json var jsonSvr struct { outDir string stdout bool maxSpans int spansSeen int } func serverJsonCmd(config *Config) *cobra.Command { cmd := cobra.Command{ Use: "json", Short: "write spans to json or stdout", Long: "", Run: doServerJson, } addCommonParams(&cmd, config) cmd.Flags().StringVar(&jsonSvr.outDir, "dir", "", "write spans to json in the specified directory") cmd.Flags().BoolVar(&jsonSvr.stdout, "stdout", false, "write span jsons to stdout") cmd.Flags().IntVar(&jsonSvr.maxSpans, "max-spans", 0, "exit the server after this many spans come in") return &cmd } func doServerJson(cmd *cobra.Command, args []string) { config := getConfig(cmd.Context()) stop := func(otlpserver.OtlpServer) {} cs := otlpserver.NewGrpcServer(renderJson, stop) // stops the grpc server after timeout timeout := config.ParseCliTimeout() if timeout > 0 { go func() { time.Sleep(timeout) cs.Stop() }() } runServer(config, renderJson, stop) } // writeFile takes the spans and events and writes them out to json files in the // tid/sid/span.json and tid/sid/events.json files. func renderJson(ctx context.Context, span *tracepb.Span, events []*tracepb.Span_Event, ss *tracepb.ResourceSpans, headers map[string]string, meta map[string]string) bool { jsonSvr.spansSeen++ // count spans for exiting on --max-spans // TODO: check for existence of outdir and error when it doesn't exist var outpath string if jsonSvr.outDir != "" { // create trace directory outpath = filepath.Join(jsonSvr.outDir, hex.EncodeToString(span.TraceId)) os.Mkdir(outpath, 0755) // ignore errors for now // create span directory outpath = filepath.Join(outpath, hex.EncodeToString(span.SpanId)) os.Mkdir(outpath, 0755) // ignore errors for now } // write span to file // TODO: if a span comes in twice should we continue to overwrite span.json // or attempt some kind of merge? (e.g. of attributes) sjs, err := json.Marshal(span) if err != nil { log.Fatalf("failed to marshal span to json: %s", err) } // write the span to /path/tid/sid/span.json writeJson(outpath, "span.json", sjs) // only write events out if there is at least one for i, e := range events { ejs, err := json.Marshal(e) if err != nil { log.Fatalf("failed to marshal span event to json: %s", err) } // write events to /path/tid/sid/event-%d.json // TODO: ordering might be a problem if people rely on it... filename := "event-" + strconv.Itoa(i) + ".json" writeJson(outpath, filename, ejs) } if jsonSvr.maxSpans > 0 && jsonSvr.spansSeen >= jsonSvr.maxSpans { return true // will cause the server loop to exit } return false } // writeJson takes a directory path, a filename, and json. When the path is not empty // string the json is written to path/filename. If --stdout was specified the json will // be printed as a line to stdout. func writeJson(path, filename string, js []byte) { if path != "" { spanfile := filepath.Join(path, filename) err := os.WriteFile(spanfile, js, 0644) if err != nil { log.Fatalf("could not write to file %q: %s", spanfile, err) } } if jsonSvr.stdout { os.Stdout.Write(js) os.Stdout.WriteString("\n") } } ================================================ FILE: otelcli/server_tui.go ================================================ package otelcli import ( "context" "encoding/hex" "log" "math" "sort" "strconv" "github.com/equinix-labs/otel-cli/otlpclient" "github.com/equinix-labs/otel-cli/otlpserver" "github.com/pterm/pterm" "github.com/spf13/cobra" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) var tuiServer struct { lines SpanEventUnionList traces map[string]*tracepb.Span // for looking up top span of trace by trace id area *pterm.AreaPrinter } func serverTuiCmd(config *Config) *cobra.Command { cmd := cobra.Command{ Use: "tui", Short: "display spans in a terminal UI", Long: `Run otel-cli as an OTLP server with a terminal UI that displays traces. # run otel-cli as a local server and print spans to the console as a table otel-cli server tui`, Run: doServerTui, } addCommonParams(&cmd, config) return &cmd } // doServerTui implements the 'otel-cli server tui' subcommand. func doServerTui(cmd *cobra.Command, args []string) { config := getConfig(cmd.Context()) area, err := pterm.DefaultArea.Start() if err != nil { log.Fatalf("failed to set up terminal for rendering: %s", err) } tuiServer.area = area tuiServer.lines = []SpanEventUnion{} tuiServer.traces = make(map[string]*tracepb.Span) stop := func(otlpserver.OtlpServer) { tuiServer.area.Stop() } runServer(config, renderTui, stop) } // renderTui takes the given span and events, appends them to the in-memory // event list, sorts that, then prints it as a pterm table. func renderTui(ctx context.Context, span *tracepb.Span, events []*tracepb.Span_Event, rss *tracepb.ResourceSpans, headers map[string]string, meta map[string]string) bool { spanTraceId := hex.EncodeToString(span.TraceId) if _, ok := tuiServer.traces[spanTraceId]; !ok { tuiServer.traces[spanTraceId] = span } tuiServer.lines = append(tuiServer.lines, SpanEventUnion{Span: span}) for _, e := range events { tuiServer.lines = append(tuiServer.lines, SpanEventUnion{Span: span, Event: e}) } sort.Sort(tuiServer.lines) trimTuiEvents() td := pterm.TableData{ {"Trace ID", "Span ID", "Parent", "Name", "Kind", "Start", "End", "Elapsed"}, } for _, line := range tuiServer.lines { var traceId, spanId, parent, name, kind string var startOffset, endOffset, elapsed int64 if line.IsSpan() { name = line.Span.Name kind = otlpclient.SpanKindIntToString(line.Span.GetKind()) traceId = line.TraceIdString() spanId = line.SpanIdString() if tspan, ok := tuiServer.traces[traceId]; ok { startOffset = roundedDelta(line.Span.StartTimeUnixNano, tspan.StartTimeUnixNano) endOffset = roundedDelta(line.Span.EndTimeUnixNano, tspan.StartTimeUnixNano) } else { endOffset = roundedDelta(line.Span.EndTimeUnixNano, line.Span.StartTimeUnixNano) } if len(line.Span.ParentSpanId) > 0 { traceId = "" // hide it after printing the first trace id parent = hex.EncodeToString(line.Span.ParentSpanId) } elapsed = endOffset - startOffset } else { // span events name = line.Event.Name kind = "event" traceId = "" // hide ids on events to make screen less busy parent = line.SpanIdString() if tspan, ok := tuiServer.traces[traceId]; ok { startOffset = roundedDelta(line.Event.TimeUnixNano, tspan.StartTimeUnixNano) } else { startOffset = roundedDelta(line.Event.TimeUnixNano, line.Span.StartTimeUnixNano) } endOffset = startOffset elapsed = 0 } td = append(td, []string{ traceId, spanId, parent, name, kind, strconv.FormatInt(startOffset, 10), strconv.FormatInt(endOffset, 10), strconv.FormatInt(elapsed, 10), }) } tuiServer.area.Update(pterm.DefaultTable.WithHasHeader().WithData(td).Srender()) return false // keep running until user hits ctrl-c } // roundedDelta takes to uint64 nanos values, cuts them down to milliseconds, // takes the delta (absolute value, so any order is fine), and returns an int64 // of ms between the values. func roundedDelta(ts1, ts2 uint64) int64 { deltaMs := math.Abs(float64(ts1/1000000) - float64(ts2/1000000)) rounded := math.Round(deltaMs) return int64(rounded) } // trimEvents looks to see if there's room on the screen for the number of incoming // events and removes the oldest traces until there's room // TODO: how to hand really huge traces that would scroll off the screen entirely? func trimTuiEvents() { maxRows := pterm.GetTerminalHeight() // TODO: allow override of this? if len(tuiServer.lines) == 0 || len(tuiServer.lines) < maxRows { return // plenty of room, nothing to do } end := len(tuiServer.lines) - 1 // should never happen but default to all need := (len(tuiServer.lines) - maxRows) + 2 // trim at least this many tid := tuiServer.lines[0].TraceIdString() // we always remove the whole trace for i, v := range tuiServer.lines { if v.TraceIdString() == tid { end = i } else { if end+1 < need { // trace id changed, advance the trim point, and change trace ids tid = v.TraceIdString() end = i } else { break // made enough room, we can quit early } } } // might need to realloc to not leak memory here? tuiServer.lines = tuiServer.lines[end:] } // SpanEventUnion is for server_tui so it can sort spans and events together // by timestamp. type SpanEventUnion struct { Span *tracepb.Span Event *tracepb.Span_Event } func (seu *SpanEventUnion) TraceIdString() string { return hex.EncodeToString(seu.Span.TraceId) } func (seu *SpanEventUnion) SpanIdString() string { return hex.EncodeToString(seu.Span.SpanId) } func (seu *SpanEventUnion) UnixNanos() uint64 { if seu.IsSpan() { return seu.Span.StartTimeUnixNano } else { return seu.Event.TimeUnixNano } } // IsSpan returns true if this union is for an event. Span is always populated // but Event is only populated for events. func (seu *SpanEventUnion) IsSpan() bool { return seu.Event == nil } // SpanEventUnionList is a sortable list of SpanEventUnion, sorted on timestamp. type SpanEventUnionList []SpanEventUnion func (sl SpanEventUnionList) Len() int { return len(sl) } func (sl SpanEventUnionList) Swap(i, j int) { sl[i], sl[j] = sl[j], sl[i] } func (sl SpanEventUnionList) Less(i, j int) bool { return sl[i].UnixNanos() < sl[j].UnixNanos() } ================================================ FILE: otelcli/span.go ================================================ package otelcli import ( "context" "os" "time" "github.com/equinix-labs/otel-cli/otlpclient" "github.com/spf13/cobra" ) // spanCmd represents the span command func spanCmd(config *Config) *cobra.Command { cmd := cobra.Command{ Use: "span", Short: "create an OpenTelemetry span and send it", Long: `Create an OpenTelemetry span as specified and send it along. The span can be customized with a start/end time via RFC3339 or Unix epoch format, with support for nanoseconds on both. Example: otel-cli span \ --service "my-application" \ --name "send data to the server" \ --start 2021-03-24T07:28:05.12345Z \ --end $(date +%s.%N) \ --attrs "os.kernel=$(uname -r)" \ --tp-print `, Run: doSpan, } cmd.Flags().SortFlags = false addCommonParams(&cmd, config) addSpanParams(&cmd, config) addSpanStartEndParams(&cmd, config) addAttrParams(&cmd, config) addClientParams(&cmd, config) // subcommands cmd.AddCommand(spanBgCmd(config)) cmd.AddCommand(spanEventCmd(config)) cmd.AddCommand(spanEndCmd(config)) return &cmd } func doSpan(cmd *cobra.Command, args []string) { ctx := cmd.Context() config := getConfig(ctx) ctx, cancel := context.WithDeadline(ctx, time.Now().Add(config.GetTimeout())) defer cancel() ctx, client := StartClient(ctx, config) span := config.NewProtobufSpan() ctx, err := otlpclient.SendSpan(ctx, client, config, span) config.SoftFailIfErr(err) _, err = client.Stop(ctx) config.SoftFailIfErr(err) config.PropagateTraceparent(span, os.Stdout) } ================================================ FILE: otelcli/span_background.go ================================================ package otelcli import ( "context" "os" "os/signal" "path" "strconv" "syscall" "time" "github.com/equinix-labs/otel-cli/otlpclient" "github.com/spf13/cobra" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) // spanBgCmd represents the span background command func spanBgCmd(config *Config) *cobra.Command { cmd := cobra.Command{ Use: "background", Short: "create background span handler", Long: `Creates a background span handler that listens on a Unix socket so you can add events to it. The span is closed when the process exits from timeout, (catchable) signals, or deliberate exit. socket_dir=$(mktemp -d) otel-cli span background \ --service "my-long-script.sh" \ --name "run the script" \ --attrs "os.kernel=$(uname -r)" \ --timeout 60 \ --sockdir $socket_dir & # <-- notice the & otel-cli span event \ --sockdir $socket_dir \ --name "something interesting happened!" \ --attrs "foo=bar" `, Run: doSpanBackground, } defaults := DefaultConfig() cmd.Flags().SortFlags = false // don't sort subcommands // it seems like the socket should be required for background but it's // only necessary for adding events to the span. it should be fine to // start a background span at the top of a script then let it fall off // at the end to get an easy span cmd.Flags().StringVar(&config.BackgroundSockdir, "sockdir", defaults.BackgroundSockdir, "a directory where a socket can be placed safely") cmd.Flags().IntVar(&config.BackgroundParentPollMs, "parent-poll", defaults.BackgroundParentPollMs, "number of milliseconds to wait between checking for whether the parent process exited") cmd.Flags().BoolVar(&config.BackgroundWait, "wait", defaults.BackgroundWait, "wait for background to be fully started and then return") cmd.Flags().BoolVar(&config.BackgroundSkipParentPidCheck, "skip-pid-check", defaults.BackgroundSkipParentPidCheck, "disable checking parent pid") addCommonParams(&cmd, config) addSpanParams(&cmd, config) addClientParams(&cmd, config) addAttrParams(&cmd, config) return &cmd } func doSpanBackground(cmd *cobra.Command, args []string) { ctx := cmd.Context() config := getConfig(ctx) started := time.Now() ctx, client := StartClient(ctx, config) // special case --wait, createBgClient() will wait for the socket to show up // then connect and send a no-op RPC. by this time e.g. --tp-carrier should // be all done and everything is ready to go without race conditions if config.BackgroundWait { client, shutdown := createBgClient(config) defer shutdown() err := client.Call("BgSpan.Wait", &struct{}{}, &struct{}{}) if err != nil { config.SoftFail("error while waiting on span background: %s", err) } return } span := config.NewProtobufSpan() // span background is a bit different from span/exec in that it might be // hanging out while other spans are created, so it does the traceparent // propagation before the server starts, instead of after config.PropagateTraceparent(span, os.Stdout) sockfile := path.Join(config.BackgroundSockdir, spanBgSockfilename) bgs := createBgServer(ctx, sockfile, span) // set up signal handlers to cleanly exit on SIGINT/SIGTERM etc signals := make(chan os.Signal, 1) signal.Notify(signals, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) go func() { <-signals bgs.Shutdown() }() // in order to exit at the end of scripts this program needs a way to know // when the parent is gone. the most straightforward approach that should // be fine on most Unix-ish operating systems is to poll getppid and exit // when the parent process pid changes if !config.BackgroundSkipParentPidCheck { ppid := os.Getppid() go func() { for { time.Sleep(time.Duration(config.BackgroundParentPollMs) * time.Millisecond) // check if the parent process has changed, exit when it does cppid := os.Getppid() if cppid != ppid { rt := time.Since(started) spanBgEndEvent(ctx, span, "parent_exited", rt) bgs.Shutdown() } } }() } // start the timeout goroutine, this is a little late but the server // has to be up for this to make much sense if timeout := config.ParseCliTimeout(); timeout > 0 { go func() { time.Sleep(timeout) rt := time.Since(started) spanBgEndEvent(ctx, span, "timeout", rt) bgs.Shutdown() }() } // will block until bgs.Shutdown() bgs.Run() span.EndTimeUnixNano = uint64(time.Now().UnixNano()) ctx, cancel := context.WithDeadline(ctx, time.Now().Add(config.GetTimeout())) defer cancel() _, err := otlpclient.SendSpan(ctx, client, config, span) if err != nil { config.SoftFail("Sending span failed: %s", err) } } // spanBgEndEvent adds an event with the provided name, to the provided span // with uptime.milliseconds and timeout.seconds attributes. func spanBgEndEvent(ctx context.Context, span *tracepb.Span, name string, elapsed time.Duration) { config := getConfig(ctx) event := otlpclient.NewProtobufSpanEvent() event.Name = name event.Attributes = otlpclient.StringMapAttrsToProtobuf(map[string]string{ "config.timeout": config.Timeout, "otel-cli.runtime_ms": strconv.FormatInt(elapsed.Milliseconds(), 10), }) span.Events = append(span.Events, event) } ================================================ FILE: otelcli/span_background_server.go ================================================ package otelcli import ( "context" "encoding/hex" "fmt" "net" "net/rpc" "net/rpc/jsonrpc" "os" "path" "sync" "time" "github.com/equinix-labs/otel-cli/otlpclient" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) // BgSpan is what is returned to all RPC clients and its methods are exported. type BgSpan struct { TraceID string `json:"trace_id"` SpanID string `json:"span_id"` Traceparent string `json:"traceparent"` Error string `json:"error"` config Config span *tracepb.Span shutdown func() } // BgSpanEvent is a span event that the client will send. type BgSpanEvent struct { Name string `json:"name"` Timestamp string `json:"timestamp"` Attributes map[string]string } // BgEnd is an empty struct that can be sent to call End(). type BgEnd struct { Attributes map[string]string `json:"span_attributes" env:"OTEL_CLI_ATTRIBUTES"` StatusCode string `json:"status_code"` StatusDesc string `json:"status_description"` } // AddEvent takes a BgSpanEvent from the client and attaches an event to the span. func (bs BgSpan) AddEvent(bse *BgSpanEvent, reply *BgSpan) error { reply.TraceID = hex.EncodeToString(bs.span.TraceId) reply.SpanID = hex.EncodeToString(bs.span.SpanId) reply.Traceparent = otlpclient.TraceparentFromProtobufSpan(bs.span, bs.config.GetIsRecording()).Encode() ts, err := time.Parse(time.RFC3339Nano, bse.Timestamp) if err != nil { reply.Error = fmt.Sprintf("%s", err) return err } event := otlpclient.NewProtobufSpanEvent() event.Name = bse.Name event.TimeUnixNano = uint64(ts.UnixNano()) event.Attributes = otlpclient.StringMapAttrsToProtobuf(bse.Attributes) bs.span.Events = append(bs.span.Events, event) return nil } // Wait is a no-op RPC for validating the background server is up and running. func (bs BgSpan) Wait(in, reply *struct{}) error { return nil } // End takes a BgEnd (empty) struct, replies with the usual trace info, then // ends the span end exits the background process. func (bs BgSpan) End(in *BgEnd, reply *BgSpan) error { // handle --attrs arg to span end by retrieving and merging with/overwriting existing attribtues attrs := make(map[string]string) for k, v := range otlpclient.SpanAttributesToStringMap(bs.span) { attrs[k] = v } for key, value := range in.Attributes { attrs[key] = value } // handle --status-code and --status-description args to span end c := bs.config.WithStatusCode(in.StatusCode).WithStatusDescription(in.StatusDesc).WithAttributes(attrs) otlpclient.SetSpanStatus(bs.span, c.StatusCode, c.StatusDescription) bs.span.Attributes = otlpclient.StringMapAttrsToProtobuf(c.Attributes) // running the shutdown as a goroutine prevents the client from getting an // error here when the server gets closed. defer didn't do the trick. go bs.shutdown() return nil } // bgServer is a handle for a span background server. type bgServer struct { sockfile string listener net.Listener quit chan struct{} wg sync.WaitGroup config Config } // createBgServer opens a new span background server on a unix socket and // returns with the server ready to go. Not expected to block. func createBgServer(ctx context.Context, sockfile string, span *tracepb.Span) *bgServer { var err error config := getConfig(ctx) bgs := bgServer{ sockfile: sockfile, quit: make(chan struct{}), config: config, } // TODO: be safer? if err = os.RemoveAll(sockfile); err != nil { config.SoftFail("failed while cleaning up for socket file '%s': %s", sockfile, err) } bgspan := BgSpan{ TraceID: hex.EncodeToString(span.TraceId), SpanID: hex.EncodeToString(span.SpanId), config: config, span: span, shutdown: func() { bgs.Shutdown() }, } // makes methods on BgSpan available over RPC rpc.Register(&bgspan) bgs.listener, err = net.Listen("unix", sockfile) if err != nil { config.SoftFail("unable to listen on unix socket '%s': %s", sockfile, err) } bgs.wg.Add(1) // cleanup will block until this is done return &bgs } // Run will block until shutdown, accepting connections and processing them. func (bgs *bgServer) Run() { // TODO: add controls to exit loop for { conn, err := bgs.listener.Accept() if err != nil { select { case <-bgs.quit: // quitting gracefully return default: bgs.config.SoftFail("error while accepting connection: %s", err) } } bgs.wg.Add(1) go func() { defer conn.Close() jsonrpc.ServeConn(conn) bgs.wg.Done() }() } } // Shutdown does a controlled shutdown of the background server. Blocks until // the server is turned down cleanly and it's safe to exit. func (bgs *bgServer) Shutdown() { os.Remove(bgs.sockfile) close(bgs.quit) bgs.listener.Close() bgs.wg.Wait() } // createBgClient sets up a client connection to the unix socket jsonrpc server // and returns the rpc client handle and a shutdown function that should be // deferred. func createBgClient(config Config) (*rpc.Client, func()) { sockfile := path.Join(config.BackgroundSockdir, spanBgSockfilename) started := time.Now() timeout := config.ParseCliTimeout() // wait for the socket file to show up, polling every 25ms until it does or timeout for { _, err := os.Stat(sockfile) if os.IsNotExist(err) { time.Sleep(time.Millisecond * 25) // sleep 25ms between checks } else if err != nil { config.SoftFail("failed to stat file '%s': %s", sockfile, err) } else { break } if timeout > 0 && time.Since(started) > timeout { config.SoftFail("timeout after %s while waiting for span background socket '%s' to appear", config.Timeout, sockfile) } } sock := net.UnixAddr{Name: sockfile, Net: "unix"} conn, err := net.DialUnix(sock.Net, nil, &sock) if err != nil { config.SoftFail("unable to connect to span background server at '%s': %s", config.BackgroundSockdir, err) } return jsonrpc.NewClient(conn), func() { conn.Close() } } ================================================ FILE: otelcli/span_end.go ================================================ package otelcli import ( "os" "github.com/equinix-labs/otel-cli/w3c/traceparent" "github.com/spf13/cobra" ) // spanEndCmd represents the span event command func spanEndCmd(config *Config) *cobra.Command { cmd := cobra.Command{ Use: "end", Short: "Make a span background to end itself and exit gracefully", Long: `Gracefully end a background span and have its process exit. See: otel-cli span background otel-cli span end --sockdir $sockdir \ --attrs "output.length=$(wc -l < output.txt | sed -e 's/^[[:space:]]*//') `, Run: doSpanEnd, } defaults := DefaultConfig() cmd.Flags().BoolVar(&config.Verbose, "verbose", defaults.Verbose, "print errors on failure instead of always being silent") // TODO //cmd.Flags().StringVar(&config.Timeout, "timeout", defaults.Timeout, "timeout for otel-cli operations, all timeouts in otel-cli use this value") cmd.Flags().StringVar(&config.BackgroundSockdir, "sockdir", defaults.BackgroundSockdir, "a directory where a socket can be placed safely") cmd.MarkFlagRequired("sockdir") cmd.Flags().StringVar(&config.SpanEndTime, "end", defaults.SpanEndTime, "an Unix epoch or RFC3339 timestamp for the end of the span") addSpanStatusParams(&cmd, config) addAttrParams(&cmd, config) return &cmd } func doSpanEnd(cmd *cobra.Command, args []string) { config := getConfig(cmd.Context()) client, shutdown := createBgClient(config) rpcArgs := BgEnd{ Attributes: config.Attributes, StatusCode: config.StatusCode, StatusDesc: config.StatusDescription, } res := BgSpan{} err := client.Call("BgSpan.End", rpcArgs, &res) if err != nil { config.SoftFail("error while calling background server rpc BgSpan.End: %s", err) } shutdown() tp, _ := traceparent.Parse(res.Traceparent) if config.TraceparentPrint { tp.Fprint(os.Stdout, config.TraceparentPrintExport) } } ================================================ FILE: otelcli/span_event.go ================================================ package otelcli import ( "os" "time" "github.com/equinix-labs/otel-cli/w3c/traceparent" "github.com/spf13/cobra" ) // spanEventCmd represents the span event command func spanEventCmd(config *Config) *cobra.Command { cmd := cobra.Command{ Use: "event", Short: "create an OpenTelemetry span event and add it to the background span", Long: `Create an OpenTelemetry span event as specified and send it out. See: otel-cli span background sd=$(mktemp -d) otel-cli span background --sockdir $sd otel-cli span event \ --sockdir $sd \ --name "did a cool thing" \ --time $(date +%s.%N) \ --attrs "os.kernel=$(uname -r)" `, Run: doSpanEvent, } defaults := DefaultConfig() cmd.Flags().SortFlags = false cmd.Flags().BoolVar(&config.Verbose, "verbose", defaults.Verbose, "print errors on failure instead of always being silent") // TODO //spanEventCmd.Flags().StringVar(&config.Timeout, "timeout", defaults.Timeout, "timeout for otel-cli operations, all timeouts in otel-cli use this value") cmd.Flags().StringVarP(&config.EventName, "name", "e", defaults.EventName, "set the name of the event") cmd.Flags().StringVarP(&config.EventTime, "time", "t", defaults.EventTime, "the precise time of the event in RFC3339Nano or Unix.nano format") cmd.Flags().StringVar(&config.BackgroundSockdir, "sockdir", "", "a directory where a socket can be placed safely") cmd.MarkFlagRequired("sockdir") addAttrParams(&cmd, config) return &cmd } func doSpanEvent(cmd *cobra.Command, args []string) { config := getConfig(cmd.Context()) timestamp := config.ParsedEventTime() rpcArgs := BgSpanEvent{ Name: config.EventName, Timestamp: timestamp.Format(time.RFC3339Nano), Attributes: config.Attributes, } res := BgSpan{} client, shutdown := createBgClient(config) defer shutdown() err := client.Call("BgSpan.AddEvent", rpcArgs, &res) if err != nil { config.SoftFail("error while calling background server rpc BgSpan.AddEvent: %s", err) } if config.TraceparentPrint { tp, err := traceparent.Parse(res.Traceparent) if err != nil { config.SoftFail("Could not parse traceparent: %s", err) } tp.Fprint(os.Stdout, config.TraceparentPrintExport) } } ================================================ FILE: otelcli/status.go ================================================ package otelcli import ( "context" "encoding/hex" "encoding/json" "fmt" "os" "strconv" "strings" "time" "github.com/equinix-labs/otel-cli/otlpclient" "github.com/spf13/cobra" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) // StatusOutput captures all the data we want to print out for this subcommand // and is also used in ../main_test.go for automated testing. type StatusOutput struct { Config Config `json:"config"` Spans []map[string]string `json:"spans"` SpanData map[string]string `json:"span_data"` Env map[string]string `json:"env"` Diagnostics Diagnostics `json:"diagnostics"` Errors otlpclient.ErrorList `json:"errors"` } func statusCmd(config *Config) *cobra.Command { cmd := cobra.Command{ Use: "status", Short: "send at least one canary and dump status", Long: `This subcommand is still experimental and the output format is not yet frozen. By default just one canary is sent. When --canary-count is set, that number of canaries are sent. If --canary-interval is set, status will sleep the specified duration between canaries, up to --timeout (default 1s). Example: otel-cli status otel-cli status --canary-count 10 --canary-interval 10 --timeout 10s `, Run: doStatus, } defaults := DefaultConfig() cmd.Flags().IntVar(&config.StatusCanaryCount, "canary-count", defaults.StatusCanaryCount, "number of canaries to send") cmd.Flags().StringVar(&config.StatusCanaryInterval, "canary-interval", defaults.StatusCanaryInterval, "number of milliseconds to wait between canaries") addCommonParams(&cmd, config) addClientParams(&cmd, config) addSpanParams(&cmd, config) return &cmd } func doStatus(cmd *cobra.Command, args []string) { var err error var exitCode int allSpans := []map[string]string{} ctx := cmd.Context() config := getConfig(ctx) ctx, cancel := context.WithDeadline(ctx, time.Now().Add(config.GetTimeout())) defer cancel() ctx, client := StartClient(ctx, config) env := make(map[string]string) for _, e := range os.Environ() { parts := strings.SplitN(e, "=", 2) if len(parts) == 2 { // TODO: this is just enough so I can sleep tonight. // should be a list at top of file and needs a flag to turn it off // TODO: for sure need to mask OTEL_EXPORTER_OTLP_HEADERS if strings.Contains(strings.ToLower(parts[0]), "token") || parts[0] == "OTEL_EXPORTER_OTLP_HEADERS" { env[parts[0]] = "--- redacted ---" } else { env[parts[0]] = parts[1] } } else { config.SoftFail("BUG in otel-cli: this shouldn't happen") } } var canaryCount int var lastSpan *tracepb.Span deadline := time.Now().Add(config.GetTimeout()) interval := config.ParseStatusCanaryInterval() for { // should be rare but a caller could request 0 canaries, in which case the // client will be started and stopped, but no canaries sent if config.StatusCanaryCount == 0 { // TODO: remove this after SpanData is eliminated lastSpan = otlpclient.NewProtobufSpan() lastSpan.Name = "unsent canary" break } span := config.NewProtobufSpan() span.Name = "otel-cli status" if canaryCount > 0 { span.Name = fmt.Sprintf("otel-cli status canary %d", canaryCount) } span.Kind = tracepb.Span_SPAN_KIND_INTERNAL // when doing multiple canaries, child each new span to the previous one if lastSpan != nil { span.TraceId = lastSpan.TraceId span.ParentSpanId = lastSpan.SpanId } lastSpan = span allSpans = append(allSpans, otlpclient.SpanToStringMap(span, nil)) // send it to the server. ignore errors here, they'll happen for sure // and the base errors will be tunneled up through otlpclient.GetErrorList() ctx, _ = otlpclient.SendSpan(ctx, client, config, span) canaryCount++ if canaryCount == config.StatusCanaryCount { break } else if time.Now().After(deadline) { break } else { time.Sleep(interval) } } ctx, err = client.Stop(ctx) if err != nil { config.SoftFail("client.Stop() failed: %s", err) } // otlpclient saves all errors to a key in context so they can be used // to validate assumptions here & in tests errorList := otlpclient.GetErrorList(ctx) // TODO: does it make sense to turn SpanData into a list of spans? outData := StatusOutput{ Config: config, Env: env, Spans: allSpans, // use only the last span's data here, leftover from when status only // ever sent one canary // legacy, will be removed once test suite is updated SpanData: map[string]string{ "trace_id": hex.EncodeToString(lastSpan.TraceId), "span_id": hex.EncodeToString(lastSpan.SpanId), "is_sampled": strconv.FormatBool(config.GetIsRecording()), }, // Diagnostics is deprecated, being replaced by Errors below and eventually // another stringmap of stuff that was tunneled through context.Context Diagnostics: Diag, Errors: errorList, } js, err := json.MarshalIndent(outData, "", " ") config.SoftFailIfErr(err) os.Stdout.Write(js) os.Stdout.WriteString("\n") os.Exit(exitCode) } ================================================ FILE: otelcli/version.go ================================================ package otelcli import ( "fmt" "os" "strings" "github.com/spf13/cobra" ) // versionCmd prints the version and exits. func versionCmd(_ *Config) *cobra.Command { cmd := cobra.Command{ Use: "version", Short: "print otel-cli's version, commit, release date to stdout", Run: doVersion, } return &cmd } func doVersion(cmd *cobra.Command, args []string) { ctx := cmd.Context() config := getConfig(ctx) fmt.Fprintln(os.Stdout, config.Version) } // FormatVersion pretty-prints the global version, commit, and date values into // a string to enable the --version flag. Public to be called from main. func FormatVersion(version, commit, date string) string { parts := []string{} if version != "" { parts = append(parts, version) } if commit != "" { parts = append(parts, commit) } if date != "" { parts = append(parts, date) } if len(parts) == 0 { parts = append(parts, "unknown") } return strings.Join(parts, " ") } ================================================ FILE: otelcli/version_test.go ================================================ package otelcli import ( "testing" "github.com/google/go-cmp/cmp" ) func TestFormatVersion(t *testing.T) { emptyVals := FormatVersion("", "", "") if diff := cmp.Diff("unknown", emptyVals); diff != "" { t.Fatalf("FormatVersion() mismatch (-want +got):\n%s", diff) } versionOnly := FormatVersion("0.0000", "", "") if diff := cmp.Diff("0.0000", versionOnly); diff != "" { t.Fatalf("FormatVersion() mismatch (-want +got):\n%s", diff) } loaded := FormatVersion("0.0000", "e48e468116baa5bd864f4057fc9a0f0774641f1a", "Wed Oct 5 12:28:07 2022 -0400") if diff := cmp.Diff("0.0000 e48e468116baa5bd864f4057fc9a0f0774641f1a Wed Oct 5 12:28:07 2022 -0400", loaded); diff != "" { t.Fatalf("FormatVersion() mismatch (-want +got):\n%s", diff) } } ================================================ FILE: otlpclient/otlp_client.go ================================================ // Package otlpclient implements a simple OTLP client, directly working with // protobuf, gRPC, and net/http with minimal abstractions. package otlpclient import ( "context" "crypto/tls" "fmt" "net/url" "time" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/sdk/resource" semconv "go.opentelemetry.io/otel/semconv/v1.17.0" commonpb "go.opentelemetry.io/proto/otlp/common/v1" resourcepb "go.opentelemetry.io/proto/otlp/resource/v1" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) // OTLPClient is an interface that allows for StartClient to return either // gRPC or HTTP clients. type OTLPClient interface { Start(context.Context) (context.Context, error) UploadTraces(context.Context, []*tracepb.ResourceSpans) (context.Context, error) Stop(context.Context) (context.Context, error) } // OTLPConfig interface defines all of the methods required to configure OTLP clients. type OTLPConfig interface { GetTlsConfig() *tls.Config GetIsRecording() bool GetEndpoint() *url.URL GetInsecure() bool GetTimeout() time.Duration GetHeaders() map[string]string GetVersion() string GetServiceName() string } // SendSpan connects to the OTLP server, sends the span, and disconnects. func SendSpan(ctx context.Context, client OTLPClient, config OTLPConfig, span *tracepb.Span) (context.Context, error) { if !config.GetIsRecording() { return ctx, nil } resourceAttrs, err := resourceAttributes(ctx, config.GetServiceName()) if err != nil { return ctx, err } rsps := []*tracepb.ResourceSpans{ { Resource: &resourcepb.Resource{ Attributes: resourceAttrs, }, ScopeSpans: []*tracepb.ScopeSpans{{ Scope: &commonpb.InstrumentationScope{ Name: "github.com/equinix-labs/otel-cli", Version: config.GetVersion(), Attributes: []*commonpb.KeyValue{}, DroppedAttributesCount: 0, }, Spans: []*tracepb.Span{span}, SchemaUrl: semconv.SchemaURL, }}, SchemaUrl: semconv.SchemaURL, }, } ctx, err = client.UploadTraces(ctx, rsps) if err != nil { return SaveError(ctx, time.Now(), err) } return ctx, nil } // resourceAttributes calls the OTel SDK to get automatic resource attrs and // returns them converted to []*commonpb.KeyValue for use with protobuf. func resourceAttributes(ctx context.Context, serviceName string) ([]*commonpb.KeyValue, error) { // set the service name that will show up in tracing UIs resOpts := []resource.Option{ resource.WithAttributes(semconv.ServiceNameKey.String(serviceName)), resource.WithFromEnv(), // maybe switch to manually loading this envvar? // TODO: make these autodetectors configurable //resource.WithHost(), //resource.WithOS(), //resource.WithProcess(), //resource.WithContainer(), } res, err := resource.New(ctx, resOpts...) if err != nil { return nil, fmt.Errorf("failed to create OpenTelemetry service name resource: %s", err) } attrs := []*commonpb.KeyValue{} for _, attr := range res.Attributes() { av := new(commonpb.AnyValue) // does not implement slice types... should be fine? switch attr.Value.Type() { case attribute.BOOL: av.Value = &commonpb.AnyValue_BoolValue{BoolValue: attr.Value.AsBool()} case attribute.INT64: av.Value = &commonpb.AnyValue_IntValue{IntValue: attr.Value.AsInt64()} case attribute.FLOAT64: av.Value = &commonpb.AnyValue_DoubleValue{DoubleValue: attr.Value.AsFloat64()} case attribute.STRING: av.Value = &commonpb.AnyValue_StringValue{StringValue: attr.Value.AsString()} default: return nil, fmt.Errorf("BUG: unable to convert resource attribute, please file an issue") } ckv := commonpb.KeyValue{ Key: string(attr.Key), Value: av, } attrs = append(attrs, &ckv) } return attrs, nil } // otlpClientCtxKey is a type for storing otlp client information in context.Context safely. type otlpClientCtxKey string // TimestampedError is a timestamp + error string, to be stored in an ErrorList type TimestampedError struct { Timestamp time.Time `json:"timestamp"` Error string `json:"error"` } // ErrorList is a list of TimestampedError type ErrorList []TimestampedError // errorListKey() returns the typed key used to store the error list in context. func errorListKey() otlpClientCtxKey { return otlpClientCtxKey("otlp_errors") } // GetErrorList retrieves the error list from context and returns it. If the list // is uninitialized, it initializes it in the returned context. func GetErrorList(ctx context.Context) ErrorList { if cv := ctx.Value(errorListKey()); cv != nil { if l, ok := cv.(ErrorList); ok { return l } else { panic("BUG: failed to unwrap error list, please report an issue") } } else { return ErrorList{} } } // SaveError writes the provided error to the ErrorList in ctx, returning an // updated ctx. func SaveError(ctx context.Context, t time.Time, err error) (context.Context, error) { if err == nil { return ctx, nil } //otelcli.Diag.SetError(err) // legacy, will go away when Diag is removed te := TimestampedError{ Timestamp: t, Error: err.Error(), } errorList := GetErrorList(ctx) newList := append(errorList, te) ctx = context.WithValue(ctx, errorListKey(), newList) return ctx, err } // retry calls the provided function and expects it to return (true, wait, err) // to keep retrying, and (false, wait, err) to stop retrying and return. // The wait value is a time.Duration so the server can recommend a backoff // and it will be followed. // // This is a minimal retry mechanism that backs off linearly, 100ms at a time, // up to a maximum of 5 seconds. // While there are many robust implementations of retries out there, this one // is just ~20 LoC and seems to work fine for otel-cli's modest needs. It should // be rare for otel-cli to have a long timeout in the first place, and when it // does, maybe it's ok to wait a few seconds. // TODO: provide --otlp-retries (or something like that) option on CLI // TODO: --otlp-retry-sleep? --otlp-retry-timeout? // TODO: span events? hmm... feels weird to plumb spans this deep into the client // but it's probably fine? func retry(ctx context.Context, _ OTLPConfig, fun retryFun) (context.Context, error) { deadline, haveDL := ctx.Deadline() if !haveDL { return ctx, fmt.Errorf("BUG in otel-cli: no deadline set before retry()") } sleep := time.Duration(0) for { if ctx, keepGoing, wait, err := fun(ctx); err != nil { if keepGoing { if wait > 0 { if time.Now().Add(wait).After(deadline) { // wait will be after deadline, give up now return SaveError(ctx, time.Now(), err) } time.Sleep(wait) } else { time.Sleep(sleep) } if time.Now().After(deadline) { return SaveError(ctx, time.Now(), err) } // linearly increase sleep time up to 5 seconds if sleep < time.Second*5 { sleep = sleep + time.Millisecond*100 } } else { return SaveError(ctx, time.Now(), err) } } else { return ctx, nil } } } // retryFun is the function signature for functions passed to retry(). // Return (false, 0, err) to stop retrying. Return (true, 0, err) to continue // retrying until timeout. Set the middle wait arg to a time.Duration to // sleep a requested amount of time before next try type retryFun func(ctx context.Context) (ctxOut context.Context, keepGoing bool, wait time.Duration, err error) ================================================ FILE: otlpclient/otlp_client_grpc.go ================================================ package otlpclient import ( "context" "fmt" "time" coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) // GrpcClient holds the state for gRPC connections. type GrpcClient struct { conn *grpc.ClientConn client coltracepb.TraceServiceClient config OTLPConfig } // NewGrpcClient returns a fresh GrpcClient ready to Start. func NewGrpcClient(config OTLPConfig) *GrpcClient { c := GrpcClient{config: config} return &c } // Start configures and starts the connection to the gRPC server in the background. func (gc *GrpcClient) Start(ctx context.Context) (context.Context, error) { var err error endpointURL := gc.config.GetEndpoint() host := endpointURL.Hostname() if endpointURL.Port() != "" { host = host + ":" + endpointURL.Port() } grpcOpts := []grpc.DialOption{} if gc.config.GetInsecure() { grpcOpts = append(grpcOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) } else { grpcOpts = append(grpcOpts, grpc.WithTransportCredentials(credentials.NewTLS(gc.config.GetTlsConfig()))) } gc.conn, err = grpc.DialContext(ctx, host, grpcOpts...) if err != nil { return ctx, fmt.Errorf("could not connect to gRPC/OTLP: %w", err) } gc.client = coltracepb.NewTraceServiceClient(gc.conn) return ctx, nil } // UploadTraces takes a list of protobuf spans and sends them out, doing retries // on some errors as needed. // TODO: look into grpc.WaitForReady(), esp for status use cases func (gc *GrpcClient) UploadTraces(ctx context.Context, rsps []*tracepb.ResourceSpans) (context.Context, error) { // add headers onto the request headers := gc.config.GetHeaders() if len(headers) > 0 { md := metadata.New(headers) ctx = metadata.NewOutgoingContext(ctx, md) } req := coltracepb.ExportTraceServiceRequest{ResourceSpans: rsps} return retry(ctx, gc.config, func(innerCtx context.Context) (context.Context, bool, time.Duration, error) { etsr, err := gc.client.Export(innerCtx, &req) return processGrpcStatus(innerCtx, etsr, err) }) } // Stop closes the connection to the gRPC server. func (gc *GrpcClient) Stop(ctx context.Context) (context.Context, error) { return ctx, gc.conn.Close() } func processGrpcStatus(ctx context.Context, _ *coltracepb.ExportTraceServiceResponse, err error) (context.Context, bool, time.Duration, error) { if err == nil { // success! return ctx, false, 0, nil } st := status.Convert(err) if st.Code() == codes.OK { // apparently this can happen and is a success return ctx, false, 0, nil } var ri *errdetails.RetryInfo for _, d := range st.Details() { if t, ok := d.(*errdetails.RetryInfo); ok { ri = t } } // handle retriable codes, somewhat lifted from otel collector switch st.Code() { case codes.Aborted, codes.Canceled, codes.DataLoss, codes.DeadlineExceeded, codes.OutOfRange, codes.Unavailable: return ctx, true, 0, err case codes.ResourceExhausted: // only retry this one if RetryInfo was set if ri != nil && ri.RetryDelay != nil { // when RetryDelay is available, pass it back to the retry loop // so it can sleep that duration wait := time.Duration(ri.RetryDelay.Seconds)*time.Second + time.Duration(ri.RetryDelay.Nanos)*time.Nanosecond return ctx, true, wait, err } else { return ctx, false, 0, err } default: // don't retry anything else return ctx, false, 0, err } } ================================================ FILE: otlpclient/otlp_client_grpc_test.go ================================================ package otlpclient import ( "context" "testing" "time" "github.com/google/go-cmp/cmp" coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/durationpb" ) func TestProcessGrpcStatus(t *testing.T) { for i, tc := range []struct { etsr *coltracepb.ExportTraceServiceResponse keepgoing bool err error wait time.Duration }{ // simple success { etsr: &coltracepb.ExportTraceServiceResponse{}, keepgoing: false, err: nil, }, // partial success, no retry { etsr: &coltracepb.ExportTraceServiceResponse{ PartialSuccess: &coltracepb.ExportTracePartialSuccess{ RejectedSpans: 2, ErrorMessage: "whoops", }, }, keepgoing: false, err: status.Errorf(codes.OK, ""), }, // failure, unretriable { etsr: &coltracepb.ExportTraceServiceResponse{}, keepgoing: false, err: status.Errorf(codes.PermissionDenied, "test: permission denied"), }, // failure, retry { etsr: &coltracepb.ExportTraceServiceResponse{}, keepgoing: true, err: status.Errorf(codes.DeadlineExceeded, "test: should retry"), }, // failure, retry, with server-provided wait { etsr: &coltracepb.ExportTraceServiceResponse{}, keepgoing: true, err: retryWithInfo(1), wait: time.Second, }, } { ctx := context.Background() _, kg, wait, err := processGrpcStatus(ctx, tc.etsr, tc.err) if kg != tc.keepgoing { t.Errorf("keepgoing value returned %t but expected %t in test %d", kg, tc.keepgoing, i) } if tc.err == nil && err != nil { t.Errorf("received an unexpected error on test %d", i) } else if tc.err != nil && err == nil { t.Errorf("did not receive expected error on test %d", i) } else if tc.err == nil && err == nil { // success, do nothing } else if diff := cmp.Diff(tc.err.Error(), err.Error()); diff != "" { t.Errorf("error did not match testcase for test %d: %s", i, diff) } if wait != tc.wait { t.Errorf("expected a wait value of %d but got %d", tc.wait, wait) } } } func retryWithInfo(wait int64) error { var err error st := status.New(codes.ResourceExhausted, "Server unavailable") if wait > 0 { st, err = st.WithDetails(&errdetails.RetryInfo{ RetryDelay: &durationpb.Duration{Seconds: wait}, }) if err != nil { panic("error creating retry info") } } return st.Err() } ================================================ FILE: otlpclient/otlp_client_http.go ================================================ package otlpclient import ( "bytes" "context" "crypto/tls" "fmt" "io" "net" "net/http" "net/url" "time" coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" "google.golang.org/genproto/googleapis/rpc/status" "google.golang.org/protobuf/proto" ) // HttpClient holds state information for HTTP/OTLP. type HttpClient struct { client *http.Client config OTLPConfig } // NewHttpClient returns an initialized HttpClient. func NewHttpClient(config OTLPConfig) *HttpClient { c := HttpClient{config: config} return &c } // Start sets up the client configuration. // TODO: see if there's a way to background start http2 connections? func (hc *HttpClient) Start(ctx context.Context) (context.Context, error) { if hc.config.GetInsecure() { hc.client = &http.Client{Timeout: hc.config.GetTimeout()} } else { hc.client = &http.Client{ Timeout: hc.config.GetTimeout(), Transport: &http.Transport{ DialTLS: func(network, addr string) (net.Conn, error) { return tls.Dial(network, addr, hc.config.GetTlsConfig()) }, }, } } return ctx, nil } // UploadTraces sends the protobuf spans up to the HTTP server. func (hc *HttpClient) UploadTraces(ctx context.Context, rsps []*tracepb.ResourceSpans) (context.Context, error) { msg := coltracepb.ExportTraceServiceRequest{ResourceSpans: rsps} protoMsg, err := proto.Marshal(&msg) if err != nil { return ctx, fmt.Errorf("failed to marshal trace service request: %w", err) } body := bytes.NewBuffer(protoMsg) endpointURL := hc.config.GetEndpoint() req, err := http.NewRequest("POST", endpointURL.String(), body) if err != nil { return ctx, fmt.Errorf("failed to create HTTP POST request: %w", err) } for k, v := range hc.config.GetHeaders() { req.Header.Add(k, v) } req.Header.Set("Content-Type", "application/x-protobuf") return retry(ctx, hc.config, func(context.Context) (context.Context, bool, time.Duration, error) { var body []byte resp, err := hc.client.Do(req) if uerr, ok := err.(*url.Error); ok { // e.g. http on https, un-retriable error, quit now return ctx, false, 0, uerr } else { body, err = io.ReadAll(resp.Body) if err != nil { return ctx, true, 0, fmt.Errorf("io.Readall of response body failed: %w", err) } resp.Body.Close() return processHTTPStatus(ctx, resp, body) } }) } // processHTTPStatus takes the http.Response and body, returning the same bool, error // as retryFunc. Mostly it's broken out so it can be unit tested. func processHTTPStatus(ctx context.Context, resp *http.Response, body []byte) (context.Context, bool, time.Duration, error) { // #262 a vendor OTLP server is out of spec and returns JSON instead of protobuf ctype := resp.Header.Get("Content-Type") if ctype == "" { return ctx, false, 0, fmt.Errorf("server is out of specification: Content-Type header is missing or mangled") } else if ctype != "application/x-protobuf" { return ctx, false, 0, fmt.Errorf("server is out of specification: expected content type application/x-protobuf but got %q", ctype) } if resp.StatusCode >= 200 && resp.StatusCode < 300 { // success & partial success // spec says server MUST send 200 OK, we'll be generous and accept any 200 etsr := coltracepb.ExportTraceServiceResponse{} err := proto.Unmarshal(body, &etsr) if err != nil { // if the server's sending garbage, no point in retrying return ctx, false, 0, fmt.Errorf("unmarshal of server response failed: %w", err) } if partial := etsr.GetPartialSuccess(); partial != nil && partial.RejectedSpans > 0 { // spec says to stop retrying and drop rejected spans return ctx, false, 0, fmt.Errorf("partial success. %d spans were rejected", partial.GetRejectedSpans()) } else { // full success! return ctx, false, 0, nil } } else if resp.StatusCode == 429 || resp.StatusCode == 502 || resp.StatusCode == 503 || resp.StatusCode == 504 { // 429, 502, 503, and 504 must be retried according to spec return ctx, true, 0, fmt.Errorf("server responded with retriable code %d", resp.StatusCode) } else if resp.StatusCode >= 300 && resp.StatusCode < 400 { // spec doesn't say anything about 300's, ignore body and assume they're errors and unretriable return ctx, false, 0, fmt.Errorf("server returned unsupported code %d", resp.StatusCode) } else if resp.StatusCode >= 400 { // https://github.com/open-telemetry/opentelemetry-proto/blob/main/docs/specification.md#failures-1 st := status.Status{} err := proto.Unmarshal(body, &st) if err != nil { return ctx, false, 0, fmt.Errorf("unmarshal of server status failed: %w", err) } else { return ctx, false, 0, fmt.Errorf("server returned unretriable code %d with status: %s", resp.StatusCode, st.GetMessage()) } } // should never happen return ctx, false, 0, fmt.Errorf("BUG: fell through error checking with status code %d", resp.StatusCode) } // Stop does nothing for HTTP, for now. It exists to fulfill the interface. func (hc *HttpClient) Stop(ctx context.Context) (context.Context, error) { return ctx, nil } ================================================ FILE: otlpclient/otlp_client_http_test.go ================================================ package otlpclient import ( "context" "fmt" "net/http" "testing" "github.com/google/go-cmp/cmp" coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" "google.golang.org/genproto/googleapis/rpc/status" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/anypb" ) func TestProcessHTTPStatus(t *testing.T) { headers := http.Header{ "Content-Type": []string{"application/x-protobuf"}, } for _, tc := range []struct { resp *http.Response body []byte keepgoing bool err error }{ // simple success { resp: &http.Response{ StatusCode: 200, Header: headers, }, body: etsrSuccessBody(), keepgoing: false, err: nil, }, // partial success { resp: &http.Response{ StatusCode: 200, Header: headers, }, body: etsrPartialSuccessBody(), keepgoing: false, err: fmt.Errorf("partial success. 1 spans were rejected"), }, // failure, unretriable { resp: &http.Response{ StatusCode: 500, Header: headers, }, body: errorBody(500, "xyz"), keepgoing: false, err: fmt.Errorf("server returned unretriable code 500 with status: xyz"), }, // failures the spec requires retries for, 429, 502, 503, 504 { resp: &http.Response{ StatusCode: 429, Header: headers, }, body: errorBody(429, "xyz"), keepgoing: true, err: fmt.Errorf("server responded with retriable code 429"), }, { resp: &http.Response{ StatusCode: 502, Header: headers, }, body: errorBody(502, "xyz"), keepgoing: true, err: fmt.Errorf("server responded with retriable code 502"), }, { resp: &http.Response{ StatusCode: 503, Header: headers, }, body: errorBody(503, "xyz"), keepgoing: true, err: fmt.Errorf("server responded with retriable code 503"), }, { resp: &http.Response{ StatusCode: 504, Header: headers, }, body: errorBody(504, "xyz"), keepgoing: true, err: fmt.Errorf("server responded with retriable code 504"), }, // 300's are unsupported { resp: &http.Response{ StatusCode: 301, Header: headers, }, body: errorBody(301, "xyz"), keepgoing: false, err: fmt.Errorf("server returned unsupported code 301"), }, // shouldn't happen in the real world... { resp: &http.Response{Header: headers}, body: []byte(""), keepgoing: false, err: fmt.Errorf("BUG: fell through error checking with status code 0"), }, // return a decent error for out-of-spec servers that return JSON after a protobuf payload { resp: &http.Response{ StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, }, body: []byte(`{"some": "json"}`), keepgoing: false, err: fmt.Errorf(`server is out of specification: expected content type application/x-protobuf but got "application/json"`), }, // spec requires headers so report that as a server problem too { resp: &http.Response{ StatusCode: 200, // no headers! }, body: []byte(""), keepgoing: false, err: fmt.Errorf("server is out of specification: Content-Type header is missing or mangled"), }, } { ctx := context.Background() _, kg, _, err := processHTTPStatus(ctx, tc.resp, tc.body) if kg != tc.keepgoing { t.Errorf("keepgoing value returned %t but expected %t", kg, tc.keepgoing) } if tc.err == nil && err != nil { t.Errorf("received an unexpected error") } else if tc.err != nil && err == nil { t.Errorf("did not receive expected error") } else if tc.err == nil && err == nil { continue // pass } else if diff := cmp.Diff(tc.err.Error(), err.Error()); diff != "" { t.Errorf("error did not match testcase: %s", diff) } } } func etsrSuccessBody() []byte { etsr := coltracepb.ExportTraceServiceResponse{ PartialSuccess: nil, } b, _ := proto.Marshal(&etsr) return b } func etsrPartialSuccessBody() []byte { etsr := coltracepb.ExportTraceServiceResponse{ PartialSuccess: &coltracepb.ExportTracePartialSuccess{ RejectedSpans: 1, ErrorMessage: "xyz", }, } b, _ := proto.Marshal(&etsr) return b } func errorBody(c int32, message string) []byte { st := status.Status{ Code: c, Message: message, Details: []*anypb.Any{}, } b, _ := proto.Marshal(&st) return b } ================================================ FILE: otlpclient/otlp_client_null.go ================================================ package otlpclient import ( "context" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) // NullClient is an OTLP client backend for non-recording mode that drops // all data and never returns errors. type NullClient struct{} // NewNullClient returns a fresh NullClient ready to Start. func NewNullClient(config OTLPConfig) *NullClient { return &NullClient{} } // Start fulfills the interface and does nothing. func (nc *NullClient) Start(ctx context.Context) (context.Context, error) { return ctx, nil } // UploadTraces fulfills the interface and does nothing. func (nc *NullClient) UploadTraces(ctx context.Context, rsps []*tracepb.ResourceSpans) (context.Context, error) { return ctx, nil } // Stop fulfills the interface and does nothing. func (gc *NullClient) Stop(ctx context.Context) (context.Context, error) { return ctx, nil } ================================================ FILE: otlpclient/otlp_client_test.go ================================================ package otlpclient import ( "context" "fmt" "testing" "time" "github.com/google/go-cmp/cmp" ) func TestErrorLists(t *testing.T) { now := time.Now() for _, tc := range []struct { call func(context.Context) context.Context want ErrorList }{ { call: func(ctx context.Context) context.Context { err := fmt.Errorf("") ctx, _ = SaveError(ctx, now, err) return ctx }, want: ErrorList{ TimestampedError{now, ""}, }, }, } { ctx := context.Background() ctx = tc.call(ctx) list := GetErrorList(ctx) if len(list) < len(tc.want) { t.Errorf("got %d errors but expected %d", len(tc.want), len(list)) } // TODO: sort? if diff := cmp.Diff(list, tc.want); diff != "" { t.Errorf("error list mismatch (-want +got):\n%s", diff) } } } ================================================ FILE: otlpclient/protobuf_span.go ================================================ package otlpclient // Implements just enough sugar on the OTel Protocol Buffers span definition // to support otel-cli and no more. // // otel-cli does a few things that are awkward via the opentelemetry-go APIs // which are restricted for good reasons. import ( "crypto/rand" "encoding/hex" "sort" "strconv" "strings" "time" "github.com/equinix-labs/otel-cli/w3c/traceparent" commonpb "go.opentelemetry.io/proto/otlp/common/v1" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) type SpanConfig interface { } // NewProtobufSpan returns an initialized OpenTelemetry protobuf Span. func NewProtobufSpan() *tracepb.Span { now := time.Now() span := tracepb.Span{ TraceId: GetEmptyTraceId(), SpanId: GetEmptySpanId(), TraceState: "", ParentSpanId: []byte{}, Name: "BUG IN OTEL-CLI: unset", Kind: tracepb.Span_SPAN_KIND_CLIENT, StartTimeUnixNano: uint64(now.UnixNano()), EndTimeUnixNano: uint64(now.UnixNano()), Attributes: []*commonpb.KeyValue{}, DroppedAttributesCount: 0, Events: []*tracepb.Span_Event{}, DroppedEventsCount: 0, Links: []*tracepb.Span_Link{}, DroppedLinksCount: 0, Status: &tracepb.Status{ Code: tracepb.Status_STATUS_CODE_UNSET, Message: "", }, } return &span } // NewProtobufSpanEvent creates a new span event protobuf struct with reasonable // defaults and returns it. func NewProtobufSpanEvent() *tracepb.Span_Event { now := time.Now() return &tracepb.Span_Event{ TimeUnixNano: uint64(now.UnixNano()), Attributes: []*commonpb.KeyValue{}, } } // SetSpanStatus checks for status code error in the config and sets the // span's 2 values as appropriate. // Only set status description when an error status. // https://github.com/open-telemetry/opentelemetry-specification/blob/480a19d702470563d32a870932be5ddae798079c/specification/trace/api.md#set-status func SetSpanStatus(span *tracepb.Span, status string, message string) { statusCode := SpanStatusStringToInt(status) if statusCode != tracepb.Status_STATUS_CODE_UNSET { span.Status.Code = statusCode span.Status.Message = message } } // GetEmptyTraceId returns a 16-byte trace id that's all zeroes. func GetEmptyTraceId() []byte { return []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} } // GetEmptySpanId returns an 8-byte span id that's all zeroes. func GetEmptySpanId() []byte { return []byte{0, 0, 0, 0, 0, 0, 0, 0} } // GenerateTraceId generates a random 16 byte trace id func GenerateTraceId() []byte { buf := make([]byte, 16) _, err := rand.Read(buf) if err != nil { // should never happen, crash when it does panic("failed to generate random data for trace id: " + err.Error()) } return buf } // GenerateSpanId generates a random 8 byte span id func GenerateSpanId() []byte { buf := make([]byte, 8) _, err := rand.Read(buf) if err != nil { // should never happen, crash when it does panic("failed to generate random data for span id: " + err.Error()) } return buf } // SpanKindIntToString takes an integer/constant protobuf span kind value // and returns the string representation used in otel-cli. func SpanKindIntToString(kind tracepb.Span_SpanKind) string { switch kind { case tracepb.Span_SPAN_KIND_CLIENT: return "client" case tracepb.Span_SPAN_KIND_SERVER: return "server" case tracepb.Span_SPAN_KIND_PRODUCER: return "producer" case tracepb.Span_SPAN_KIND_CONSUMER: return "consumer" case tracepb.Span_SPAN_KIND_INTERNAL: return "internal" default: return "unspecified" } } // SpanKindIntToString takes a string representation of a span kind and // returns the OTel protobuf integer/constant. func SpanKindStringToInt(kind string) tracepb.Span_SpanKind { switch kind { case "client": return tracepb.Span_SPAN_KIND_CLIENT case "server": return tracepb.Span_SPAN_KIND_SERVER case "producer": return tracepb.Span_SPAN_KIND_PRODUCER case "consumer": return tracepb.Span_SPAN_KIND_CONSUMER case "internal": return tracepb.Span_SPAN_KIND_INTERNAL default: return tracepb.Span_SPAN_KIND_UNSPECIFIED } } // SpanStatusStringToInt takes a supported string span status and returns the otel // constant for it. Returns default of Unset on no match. func SpanStatusStringToInt(status string) tracepb.Status_StatusCode { switch status { case "unset": return tracepb.Status_STATUS_CODE_UNSET case "ok": return tracepb.Status_STATUS_CODE_OK case "error": return tracepb.Status_STATUS_CODE_ERROR default: return tracepb.Status_STATUS_CODE_UNSET } } // StringMapAttrsToProtobuf takes a map of string:string, such as that from --attrs // and returns them in an []*commonpb.KeyValue func StringMapAttrsToProtobuf(attributes map[string]string) []*commonpb.KeyValue { out := []*commonpb.KeyValue{} for k, v := range attributes { av := new(commonpb.AnyValue) // try to parse as numbers, and fall through to string if i, err := strconv.ParseInt(v, 0, 64); err == nil { av.Value = &commonpb.AnyValue_IntValue{IntValue: i} } else if f, err := strconv.ParseFloat(v, 64); err == nil { av.Value = &commonpb.AnyValue_DoubleValue{DoubleValue: f} } else if b, err := strconv.ParseBool(v); err == nil { av.Value = &commonpb.AnyValue_BoolValue{BoolValue: b} } else { av.Value = &commonpb.AnyValue_StringValue{StringValue: v} } akv := commonpb.KeyValue{ Key: k, Value: av, } out = append(out, &akv) } return out } // SpanAttributesToStringMap converts the span's attributes to a string map. func SpanAttributesToStringMap(span *tracepb.Span) map[string]string { out := make(map[string]string) for _, attr := range span.Attributes { out[attr.Key] = AnyValueToString(attr.GetValue()) } return out } // ResourceAttributesToStringMap converts the ResourceSpan's resource attributes to a string map. // Only used by tests for now. func ResourceAttributesToStringMap(rss *tracepb.ResourceSpans) map[string]string { if rss == nil { return map[string]string{} } out := make(map[string]string) for _, attr := range rss.Resource.Attributes { out[attr.Key] = AnyValueToString(attr.GetValue()) } return out } // AnyValueToString coverts a commonpb.KeyValue attribute to a string. func AnyValueToString(v *commonpb.AnyValue) string { if _, ok := v.Value.(*commonpb.AnyValue_StringValue); ok { return v.GetStringValue() } else if _, ok := v.Value.(*commonpb.AnyValue_IntValue); ok { return strconv.FormatInt(v.GetIntValue(), 10) } else if _, ok := v.Value.(*commonpb.AnyValue_DoubleValue); ok { return strconv.FormatFloat(v.GetDoubleValue(), byte('f'), -1, 64) } else if _, ok := v.Value.(*commonpb.AnyValue_ArrayValue); ok { values := v.GetArrayValue().GetValues() strValues := make([]string, len(values)) for i, v := range values { // recursively convert to string strValues[i] = AnyValueToString(v) } return strings.Join(strValues, ",") } return "" } // SpanToStringMap converts a span with some extra data into a stringmap. // Only used by tests for now. func SpanToStringMap(span *tracepb.Span, rss *tracepb.ResourceSpans) map[string]string { if span == nil { return map[string]string{} } return map[string]string{ "trace_id": hex.EncodeToString(span.GetTraceId()), "span_id": hex.EncodeToString(span.GetSpanId()), "parent_span_id": hex.EncodeToString(span.GetParentSpanId()), "name": span.Name, "kind": SpanKindIntToString(span.GetKind()), "start": strconv.FormatUint(span.StartTimeUnixNano, 10), "end": strconv.FormatUint(span.EndTimeUnixNano, 10), "attributes": flattenStringMap(SpanAttributesToStringMap(span), "{}"), "service_attributes": flattenStringMap(ResourceAttributesToStringMap(rss), "{}"), "status_code": strconv.FormatInt(int64(span.Status.GetCode()), 10), "status_description": span.Status.GetMessage(), } } // TraceparentFromProtobufSpan builds a Traceparent struct from the provided span. func TraceparentFromProtobufSpan(span *tracepb.Span, recording bool) traceparent.Traceparent { return traceparent.Traceparent{ Version: 0, TraceId: span.TraceId, SpanId: span.SpanId, Sampling: recording, Initialized: true, } } // flattenStringMap takes a string map and returns it flattened into a string with // keys sorted lexically so it should be mostly consistent enough for comparisons // and printing. Output is k=v,k=v style like attributes input. func flattenStringMap(mp map[string]string, emptyValue string) string { if len(mp) == 0 { return emptyValue } var out string keys := make([]string, len(mp)) // for sorting var i int for k := range mp { keys[i] = k i++ } sort.Strings(keys) for i, k := range keys { out = out + k + "=" + mp[k] if i == len(keys)-1 { break } out = out + "," } return out } ================================================ FILE: otlpclient/protobuf_span_test.go ================================================ package otlpclient import ( "bytes" "strconv" "testing" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) func TestNewProtobufSpan(t *testing.T) { span := NewProtobufSpan() // no tmuch to test since it's just an initialized struct if len(span.Name) < 1 { t.Error("span name should default to non-empty string") } if span.ParentSpanId == nil { t.Error("span parent must not be nil") } if span.Attributes == nil { t.Error("span attributes must not be nil") } if span.Events == nil { t.Error("span events must not be nil") } if span.Links == nil { t.Error("span links must not be nil") } } func TestNewProtobufSpanEvent(t *testing.T) { evt := NewProtobufSpanEvent() // similar to span above, just run the code and make sure // it doesn't blow up if evt.Attributes == nil { t.Error("span event attributes must not be nil") } } func TestGenerateTraceId(t *testing.T) { tid := GenerateTraceId() if bytes.Equal(tid, GetEmptyTraceId()) { t.Error("generated trace id is all zeroes and should be any other random value") } if len(tid) != 16 { t.Error("generated trace id must be 16 bytes") } } func TestGenerateSpanId(t *testing.T) { sid := GenerateSpanId() if bytes.Equal(sid, GetEmptySpanId()) { t.Error("generated span id is all zeroes and should be any other random value") } if len(sid) != 8 { t.Error("generated span id must be 8 bytes") } } func TestSpanKindStringToInt(t *testing.T) { for _, testcase := range []struct { name string want tracepb.Span_SpanKind }{ { name: "client", want: tracepb.Span_SPAN_KIND_CLIENT, }, { name: "server", want: tracepb.Span_SPAN_KIND_SERVER, }, { name: "producer", want: tracepb.Span_SPAN_KIND_PRODUCER, }, { name: "consumer", want: tracepb.Span_SPAN_KIND_CONSUMER, }, { name: "internal", want: tracepb.Span_SPAN_KIND_INTERNAL, }, { name: "unspecified", want: tracepb.Span_SPAN_KIND_UNSPECIFIED, }, { name: "speledwrong", want: tracepb.Span_SPAN_KIND_UNSPECIFIED, }, } { t.Run(testcase.name, func(t *testing.T) { out := SpanKindStringToInt(testcase.name) if out != testcase.want { t.Errorf("returned the wrong value, '%q', for '%s'", out, testcase.name) } }) } } func TestSpanKindIntToString(t *testing.T) { for _, testcase := range []struct { want string have tracepb.Span_SpanKind }{ { have: tracepb.Span_SPAN_KIND_CLIENT, want: "client", }, { have: tracepb.Span_SPAN_KIND_SERVER, want: "server", }, { have: tracepb.Span_SPAN_KIND_PRODUCER, want: "producer", }, { have: tracepb.Span_SPAN_KIND_CONSUMER, want: "consumer", }, { have: tracepb.Span_SPAN_KIND_INTERNAL, want: "internal", }, { have: tracepb.Span_SPAN_KIND_UNSPECIFIED, want: "unspecified", }, } { name := strconv.Itoa(int(testcase.have)) + " => " + testcase.want t.Run(name, func(t *testing.T) { out := SpanKindIntToString(testcase.have) if out != testcase.want { t.Errorf("returned the wrong value, '%q', for %d", out, int(testcase.have)) } }) } } func TestSpanStatusStringToInt(t *testing.T) { for _, testcase := range []struct { name string want tracepb.Status_StatusCode }{ { name: "unset", want: tracepb.Status_STATUS_CODE_UNSET, }, { name: "ok", want: tracepb.Status_STATUS_CODE_OK, }, { name: "error", want: tracepb.Status_STATUS_CODE_ERROR, }, { name: "cromulent", want: tracepb.Status_STATUS_CODE_UNSET, }, } { t.Run(testcase.name, func(t *testing.T) { out := SpanStatusStringToInt(testcase.name) if out != testcase.want { t.Errorf("otelSpanStatus returned the wrong value, '%q', for '%s'", out, testcase.name) } }) } } func TestCliAttrsToOtel(t *testing.T) { testAttrs := map[string]string{ "test 1 - string": "isn't testing fun?", "test 2 - int64": "111111111", "test 3 - float": "2.4391111", "test 4 - bool, true": "true", "test 5 - bool, false": "false", "test 6 - bool, True": "True", "test 7 - bool, False": "False", } otelAttrs := StringMapAttrsToProtobuf(testAttrs) // can't count on any ordering from map -> array for _, attr := range otelAttrs { key := string(attr.Key) switch key { case "test 1 - string": if attr.Value.GetStringValue() != testAttrs[key] { t.Errorf("expected value '%s' for key '%s' but got '%s'", testAttrs[key], key, attr.Value.GetStringValue()) } case "test 2 - int64": if attr.Value.GetIntValue() != 111111111 { t.Errorf("expected value '%s' for key '%s' but got %d", testAttrs[key], key, attr.Value.GetIntValue()) } case "test 3 - float": if attr.Value.GetDoubleValue() != 2.4391111 { t.Errorf("expected value '%s' for key '%s' but got %f", testAttrs[key], key, attr.Value.GetDoubleValue()) } case "test 4 - bool, true": if attr.Value.GetBoolValue() != true { t.Errorf("expected value '%s' for key '%s' but got %t", testAttrs[key], key, attr.Value.GetBoolValue()) } case "test 5 - bool, false": if attr.Value.GetBoolValue() != false { t.Errorf("expected value '%s' for key '%s' but got %t", testAttrs[key], key, attr.Value.GetBoolValue()) } case "test 6 - bool, True": if attr.Value.GetBoolValue() != true { t.Errorf("expected value '%s' for key '%s' but got %t", testAttrs[key], key, attr.Value.GetBoolValue()) } case "test 7 - bool, False": if attr.Value.GetBoolValue() != false { t.Errorf("expected value '%s' for key '%s' but got %t", testAttrs[key], key, attr.Value.GetBoolValue()) } } } } ================================================ FILE: otlpserver/grpcserver.go ================================================ package otlpserver import ( "bytes" "context" "encoding/csv" "log" "net" "sync" coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" "google.golang.org/grpc" "google.golang.org/grpc/metadata" ) // GrpcServer is a gRPC/OTLP server handle. type GrpcServer struct { server *grpc.Server callback Callback stoponce sync.Once stopper chan struct{} stopdone chan struct{} doneonce sync.Once coltracepb.UnimplementedTraceServiceServer } // NewGrpcServer takes a callback and stop function and returns a Server ready // to run with .Serve(). func NewGrpcServer(cb Callback, stop Stopper) *GrpcServer { s := GrpcServer{ server: grpc.NewServer(), callback: cb, stopper: make(chan struct{}), stopdone: make(chan struct{}, 1), } coltracepb.RegisterTraceServiceServer(s.server, &s) // single place to stop the server, used by timeout and max-spans go func() { <-s.stopper stop(&s) s.server.GracefulStop() }() return &s } // ServeGRPC takes a listener and starts the GRPC server on that listener. // Blocks until Stop() is called. func (gs *GrpcServer) Serve(listener net.Listener) error { err := gs.server.Serve(listener) gs.stopdone <- struct{}{} return err } // ListenAndServeGRPC starts a TCP listener then starts the GRPC server using // ServeGRPC for you. func (gs *GrpcServer) ListenAndServe(otlpEndpoint string) { listener, err := net.Listen("tcp", otlpEndpoint) if err != nil { log.Fatalf("failed to listen on OTLP endpoint %q: %s", otlpEndpoint, err) } if err := gs.Serve(listener); err != nil { log.Fatalf("failed to serve: %s", err) } } // Stop sends a value to the server shutdown goroutine so it stops GRPC // and calls the stop function given to newServer. Safe to call multiple times. func (gs *GrpcServer) Stop() { gs.stoponce.Do(func() { gs.stopper <- struct{}{} }) } // StopWait stops the server and waits for it to affirm shutdown. func (gs *GrpcServer) StopWait() { gs.Stop() gs.doneonce.Do(func() { <-gs.stopdone }) } // Export implements the gRPC server interface for exporting messages. func (gs *GrpcServer) Export(ctx context.Context, req *coltracepb.ExportTraceServiceRequest) (*coltracepb.ExportTraceServiceResponse, error) { // OTLP/gRPC headers are passed in metadata, copy them to serverMeta // for now. This isn't ideal but gets them exposed to the test suite. headers := make(map[string]string) if md, ok := metadata.FromIncomingContext(ctx); ok { for mdk := range md { vals := md.Get(mdk) buf := bytes.NewBuffer([]byte{}) csv.NewWriter(buf).WriteAll([][]string{vals}) headers[mdk] = buf.String() } } done := doCallback(ctx, gs.callback, req, headers, map[string]string{"proto": "grpc"}) if done { go gs.StopWait() } return &coltracepb.ExportTraceServiceResponse{}, nil } ================================================ FILE: otlpserver/httpserver.go ================================================ package otlpserver import ( "context" "encoding/json" "io" "log" "net" "net/http" coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" "google.golang.org/protobuf/proto" ) // HttpServer is a handle for otlp over http/protobuf. type HttpServer struct { server *http.Server callback Callback } // NewServer takes a callback and stop function and returns a Server ready // to run with .Serve(). func NewHttpServer(cb Callback, stop Stopper) *HttpServer { s := HttpServer{ server: &http.Server{}, callback: cb, } s.server.Handler = &s return &s } // ServeHTTP processes every request as if it is a trace regardless of // method and path or anything else. func (hs *HttpServer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { data, err := io.ReadAll(req.Body) if err != nil { log.Fatalf("Error while reading request body: %s", err) } msg := coltracepb.ExportTraceServiceRequest{} switch req.Header.Get("Content-Type") { case "application/x-protobuf": proto.Unmarshal(data, &msg) case "application/json": json.Unmarshal(data, &msg) default: rw.WriteHeader(http.StatusNotAcceptable) } meta := map[string]string{ "method": req.Method, "proto": req.Proto, "content-type": req.Header.Get("Content-Type"), "host": req.Host, "uri": req.RequestURI, } headers := make(map[string]string) for k := range req.Header { headers[k] = req.Header.Get(k) } done := doCallback(req.Context(), hs.callback, &msg, headers, meta) if done { go hs.StopWait() } } // ServeHttp takes a listener and starts the HTTP server on that listener. // Blocks until Stop() is called. func (hs *HttpServer) Serve(listener net.Listener) error { err := hs.server.Serve(listener) return err } // ListenAndServeHttp starts a TCP listener then starts the HTTP server using // ServeHttp for you. func (hs *HttpServer) ListenAndServe(otlpEndpoint string) { listener, err := net.Listen("tcp", otlpEndpoint) if err != nil { log.Fatalf("failed to listen on OTLP endpoint %q: %s", otlpEndpoint, err) } if err := hs.Serve(listener); err != nil { log.Fatalf("failed to serve: %s", err) } } // Stop closes the http server and all active connections immediately. func (hs *HttpServer) Stop() { hs.server.Close() } // StopWait stops the http server gracefully. func (hs *HttpServer) StopWait() { hs.server.Shutdown(context.Background()) } ================================================ FILE: otlpserver/server.go ================================================ // otlpserver is an OTLP server with HTTP and gRPC backends available. // It takes a lot of shortcuts to keep things simple and is not intended // to be used as a serious OTLP service. Primarily it is for the test // suite and also supports the otel-cli server features. package otlpserver import ( "context" "net" colv1 "go.opentelemetry.io/proto/otlp/collector/trace/v1" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) // Callback is a type for the function passed to newServer that is // called for each incoming span. type Callback func(context.Context, *tracepb.Span, []*tracepb.Span_Event, *tracepb.ResourceSpans, map[string]string, map[string]string) bool // Stopper is the function passed to newServer to be called when the // server is shut down. type Stopper func(OtlpServer) // OtlpServer abstracts the minimum interface required for an OTLP // server to be either HTTP or gRPC (but not both, for now). type OtlpServer interface { ListenAndServe(otlpEndpoint string) Serve(listener net.Listener) error Stop() StopWait() } // NewServer will start the requested server protocol, one of grpc, http/protobuf, // and http/json. func NewServer(protocol string, cb Callback, stop Stopper) OtlpServer { switch protocol { case "grpc": return NewGrpcServer(cb, stop) case "http": return NewHttpServer(cb, stop) } return nil } // doCallback unwraps the OTLP service request and calls the callback // for each span in the request. func doCallback(ctx context.Context, cb Callback, req *colv1.ExportTraceServiceRequest, headers map[string]string, serverMeta map[string]string) bool { rss := req.GetResourceSpans() for _, resource := range rss { scopeSpans := resource.GetScopeSpans() for _, ss := range scopeSpans { for _, span := range ss.GetSpans() { events := span.GetEvents() if events == nil { events = []*tracepb.Span_Event{} } done := cb(ctx, span, events, resource, headers, serverMeta) if done { return true } } } } return false } ================================================ FILE: release/Dockerfile ================================================ # While the top-level Dockerfile is set up for local development on otel-cli, # this Dockerfile is only for release. # # We use the Alpine base image to get the TLS trust store and not much else. # The ca-certificates-bundle packet is pre-installed in the base so no # additional packages are required. FROM alpine:latest ENTRYPOINT ["/otel-cli"] COPY otel-cli / ================================================ FILE: renovate.json ================================================ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:base" ] } ================================================ FILE: tls_for_test.go ================================================ package main_test /* * This file implements a certificate authority and certs for testing otel-cli's * TLS settings. * * Do NOT copy this code for production systems. It makes a few compromises to * optimize for testing and ephemeral certs that are totally inappropriate for * use in settings where security matters. */ import ( "bytes" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/tls" "crypto/x509" "encoding/pem" "math/big" "net" "os" "testing" "time" ) type TlsSettings struct { caFile string caPrivKeyFile string serverFile string serverPrivKeyFile string clientFile string clientPrivKeyFile string serverTLSConf *tls.Config clientTLSConf *tls.Config certpool *x509.CertPool } func generateTLSData(t *testing.T) TlsSettings { var err error var out TlsSettings expire := time.Now().Add(time.Hour) // ------------- CA ------------- ca := &x509.Certificate{ SerialNumber: big.NewInt(4317), NotBefore: time.Now(), NotAfter: expire, IsCA: true, BasicConstraintsValid: true, } // create a private key caPrivKey, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) if err != nil { t.Fatalf("error generating ca private key: %s", err) } // create a cert on the CA with the ^^ private key caBytes, err := x509.CreateCertificate(rand.Reader, ca, ca, &caPrivKey.PublicKey, caPrivKey) if err != nil { t.Fatalf("error generating ca cert: %s", err) } // get the PEM encoding that the tests will use caPEM := new(bytes.Buffer) pem.Encode(caPEM, &pem.Block{Type: "CERTIFICATE", Bytes: caBytes}) out.caFile = pemToTempFile(t, "ca-cert", caPEM) caPrivKeyPEM := new(bytes.Buffer) caPrivKeyBytes, err := x509.MarshalECPrivateKey(caPrivKey) if err != nil { t.Fatalf("error marshaling server cert: %s", err) } pem.Encode(caPrivKeyPEM, &pem.Block{Type: "EC PRIVATE KEY", Bytes: caPrivKeyBytes}) out.caPrivKeyFile = pemToTempFile(t, "ca-privkey", caPrivKeyPEM) out.certpool = x509.NewCertPool() out.certpool.AppendCertsFromPEM(caPEM.Bytes()) data := new(bytes.Buffer) pem.Encode(data, &pem.Block{Type: "EC PRIVATE KEY", Bytes: caPrivKeyBytes}) // ------------- server ------------- serverCert := &x509.Certificate{ SerialNumber: big.NewInt(4318), IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, NotBefore: time.Now(), NotAfter: expire, } serverPrivKey, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) if err != nil { t.Fatalf("error generating server private key: %s", err) } serverBytes, err := x509.CreateCertificate(rand.Reader, serverCert, ca, &serverPrivKey.PublicKey, caPrivKey) if err != nil { t.Fatalf("error generating server cert: %s", err) } serverPEM := new(bytes.Buffer) pem.Encode(serverPEM, &pem.Block{Type: "CERTIFICATE", Bytes: serverBytes}) out.serverFile = pemToTempFile(t, "server-cert", serverPEM) serverPrivKeyPEM := new(bytes.Buffer) serverPrivKeyBytes, err := x509.MarshalECPrivateKey(serverPrivKey) if err != nil { t.Fatalf("error marshaling server cert: %s", err) } pem.Encode(serverPrivKeyPEM, &pem.Block{Type: "EC PRIVATE KEY", Bytes: serverPrivKeyBytes}) out.serverPrivKeyFile = pemToTempFile(t, "server-privkey", serverPrivKeyPEM) serverCertPair, err := tls.X509KeyPair(serverPEM.Bytes(), serverPrivKeyPEM.Bytes()) if err != nil { t.Fatalf("error generating server cert pair: %s", err) } out.serverTLSConf = &tls.Config{ ClientCAs: out.certpool, Certificates: []tls.Certificate{serverCertPair}, } // ------------- client ------------- clientCert := &x509.Certificate{ SerialNumber: big.NewInt(4319), NotBefore: time.Now(), NotAfter: expire, } clientPrivKey, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) if err != nil { t.Fatalf("error generating client private key: %s", err) } clientBytes, err := x509.CreateCertificate(rand.Reader, clientCert, ca, &clientPrivKey.PublicKey, caPrivKey) if err != nil { t.Fatalf("error generating client cert: %s", err) } clientPEM := new(bytes.Buffer) pem.Encode(clientPEM, &pem.Block{Type: "CERTIFICATE", Bytes: clientBytes}) out.clientFile = pemToTempFile(t, "client-cert", clientPEM) clientPrivKeyPEM := new(bytes.Buffer) clientPrivKeyBytes, err := x509.MarshalECPrivateKey(clientPrivKey) if err != nil { t.Fatalf("error marshaling client cert: %s", err) } pem.Encode(clientPrivKeyPEM, &pem.Block{Type: "EC PRIVATE KEY", Bytes: clientPrivKeyBytes}) out.clientPrivKeyFile = pemToTempFile(t, "client-privkey", clientPrivKeyPEM) out.clientTLSConf = &tls.Config{ ServerName: "localhost", } return out } func (t TlsSettings) cleanup() { os.Remove(t.caFile) os.Remove(t.caPrivKeyFile) os.Remove(t.clientFile) os.Remove(t.clientPrivKeyFile) os.Remove(t.serverFile) os.Remove(t.serverPrivKeyFile) } func pemToTempFile(t *testing.T, tmpl string, buf *bytes.Buffer) string { tmp, err := os.CreateTemp(os.TempDir(), "otel-cli-test-"+tmpl+"-pem") if err != nil { t.Fatalf("error creating temp file: %s", err) } tmp.Write(buf.Bytes()) tmp.Close() return tmp.Name() } ================================================ FILE: w3c/traceparent/traceparent.go ================================================ // Package traceparent contains a lightweight implementation of W3C // traceparent parsing, loading from files and environment, and the reverse. package traceparent import ( "bufio" "encoding/hex" "fmt" "io" "os" "regexp" "strconv" "strings" ) var traceparentRe *regexp.Regexp var emptyTraceId = []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} var emptySpanId = []byte{0, 0, 0, 0, 0, 0, 0, 0} func init() { // only anchored at the front because traceparents can include more things // per the standard but only the first 4 are required for our uses traceparentRe = regexp.MustCompile("^([[:xdigit:]]{2})-([[:xdigit:]]{32})-([[:xdigit:]]{16})-([[:xdigit:]]{2})") } // Traceparent represents a parsed W3C traceparent. type Traceparent struct { Version int TraceId []byte SpanId []byte Sampling bool Initialized bool } // Encode returns the traceparent as a W3C formatted string. func (tp Traceparent) Encode() string { var sampling int var traceId, spanId string if tp.Sampling { sampling = 1 } if len(tp.TraceId) == 0 { traceId = hex.EncodeToString(emptyTraceId) } else { traceId = tp.TraceIdString() } if len(tp.SpanId) == 0 { spanId = hex.EncodeToString(emptySpanId) } else { spanId = tp.SpanIdString() } return fmt.Sprintf("%02d-%s-%s-%02d", tp.Version, traceId, spanId, sampling) } // TraceIdString returns the trace id in string form. func (tp Traceparent) TraceIdString() string { if len(tp.TraceId) == 0 { return hex.EncodeToString(emptyTraceId) } else { return hex.EncodeToString(tp.TraceId) } } // SpanIdString returns the span id in string form. func (tp Traceparent) SpanIdString() string { if len(tp.SpanId) == 0 { return hex.EncodeToString(emptySpanId) } else { return hex.EncodeToString(tp.SpanId) } } // LoadFromFile reads a traceparent from filename and returns a // context with the traceparent set. The format for the file as written is // just a bare traceparent string. Whitespace, "export " and "TRACEPARENT=" are // stripped automatically so the file can also be a valid shell snippet. func LoadFromFile(filename string) (Traceparent, error) { file, err := os.Open(filename) if err != nil { errOut := fmt.Errorf("could not open file '%s' for read: %s", filename, err) // only fatal when the tp carrier file is required explicitly, otherwise // just silently return the unmodified context return Traceparent{}, errOut } defer file.Close() // only use the line that contains TRACEPARENT var tp string scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // printSpanData emits comments with trace id and span id, ignore those if strings.HasPrefix(line, "#") { continue } else if strings.Contains(strings.ToUpper(line), "TRACEPARENT") { tp = line break } } // silently fail if no traceparent was found if tp == "" { return Traceparent{}, nil } // clean 'export TRACEPARENT=' and 'TRACEPARENT=' off the output tp = strings.TrimPrefix(tp, "export ") tp = strings.TrimPrefix(tp, "TRACEPARENT=") if !traceparentRe.MatchString(tp) { return Traceparent{}, fmt.Errorf("file '%s' was read but does not contain a valid traceparent", filename) } return Parse(tp) } // SaveToFile takes a context and filename and writes the tp from // that context into the specified file. func (tp Traceparent) SaveToFile(carrierFile string, export bool) error { file, err := os.OpenFile(carrierFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600) if err != nil { return fmt.Errorf("failure opening file '%s' for write: %w", carrierFile, err) } defer file.Close() return tp.Fprint(file, export) } // Fprint formats a traceparent into otel-cli's shell-compatible text format. // If the second/export param is true, the statement will be prepended with "export " // so it can be easily sourced in a shell script. func (tp Traceparent) Fprint(target io.Writer, export bool) error { // --tp-export will print "export TRACEPARENT" so it's // one less step to print to a file & source, or eval var exported string if export { exported = "export " } traceId := tp.TraceIdString() spanId := tp.SpanIdString() _, err := fmt.Fprintf(target, "# trace id: %s\n# span id: %s\n%sTRACEPARENT=%s\n", traceId, spanId, exported, tp.Encode()) return err } // LoadFromEnv loads the traceparent from the environment variable // TRACEPARENT and sets it in the returned Go context. func LoadFromEnv() (Traceparent, error) { tp := os.Getenv("TRACEPARENT") if tp == "" { return Traceparent{}, nil } return Parse(tp) } // Parse parses a string traceparent and returns the struct. func Parse(tp string) (Traceparent, error) { var err error out := Traceparent{} parts := traceparentRe.FindStringSubmatch(tp) if len(parts) != 5 { return out, fmt.Errorf("could not parse invalid traceparent %q", tp) } out.Version, err = strconv.Atoi(parts[1]) if err != nil { return out, fmt.Errorf("could not parse traceparent version component in %q", tp) } out.TraceId, err = hex.DecodeString(parts[2]) if err != nil { return out, fmt.Errorf("could not parse traceparent trace id component in %q", tp) } out.SpanId, err = hex.DecodeString(parts[3]) if err != nil { return out, fmt.Errorf("could not parse traceparent span id component in %q", tp) } sampleFlag, err := strconv.ParseInt(parts[4], 10, 64) if err != nil { return out, fmt.Errorf("could not parse traceparent sampling bits component in %q", tp) } out.Sampling = (sampleFlag == 1) // mark that this is a successfully parsed struct out.Initialized = true return out, nil } ================================================ FILE: w3c/traceparent/traceparent_test.go ================================================ package traceparent import ( "bytes" "os" "strings" "testing" "github.com/google/go-cmp/cmp" ) func TestFprint(t *testing.T) { for _, tc := range []struct { tp Traceparent export bool want string }{ // unconfigured, all zeroes { tp: Traceparent{ Version: 0, TraceId: []byte{}, SpanId: []byte{}, Sampling: false, Initialized: false, }, export: false, want: "# trace id: 00000000000000000000000000000000\n" + "# span id: 0000000000000000\n" + "TRACEPARENT=00-00000000000000000000000000000000-0000000000000000-00\n", }, // fully loaded, print all the things { tp: Traceparent{ Version: 0, TraceId: []byte{0xfe, 0xdc, 0xcb, 0xa9, 0x87, 0x65, 0x43, 0x21, 0xfe, 0xdc, 0xcb, 0xa9, 0x87, 0x65, 0x43, 0x21}, SpanId: []byte{0xde, 0xea, 0xd6, 0xbb, 0xaa, 0xbb, 0xcc, 0xdd}, Sampling: true, Initialized: true, }, export: true, want: "# trace id: fedccba987654321fedccba987654321\n" + "# span id: deead6bbaabbccdd\n" + "export TRACEPARENT=00-fedccba987654321fedccba987654321-deead6bbaabbccdd-01\n", }, // have a traceparent, but sampling is off, the tp should propagate as-is { tp: Traceparent{ Version: 0, TraceId: []byte{0xfe, 0xdc, 0xcb, 0xa9, 0x87, 0x65, 0x43, 0x21, 0xfe, 0xdc, 0xcb, 0xa9, 0x87, 0x65, 0x43, 0x21}, SpanId: []byte{0xde, 0xea, 0xd6, 0xbb, 0xaa, 0xbb, 0xcc, 0xdd}, Sampling: false, Initialized: true, }, export: false, want: "# trace id: fedccba987654321fedccba987654321\n" + "# span id: deead6bbaabbccdd\n" + // the traceparent provided should get printed "TRACEPARENT=00-fedccba987654321fedccba987654321-deead6bbaabbccdd-00\n", }, } { buf := bytes.NewBuffer([]byte{}) err := tc.tp.Fprint(buf, tc.export) if err != nil { t.Errorf("got an unexpected error: %s", err) } if diff := cmp.Diff(tc.want, buf.String()); diff != "" { t.Errorf("printed tp didn't match expected: (-want +got):\n%s", diff) } } } func TestLoadTraceparent(t *testing.T) { // make sure the environment variable isn't polluting test state os.Unsetenv("TRACEPARENT") // trace id should not change, because there's no envvar and no file tp, err := LoadFromFile(os.DevNull) if err != nil { t.Error("LoadFromFile returned an unexpected error: %w", err) } if tp.Initialized { t.Error("traceparent detected where there should be none") } // load from file only testFileTp := "00-f61fc53f926e07a9c3893b1a722e1b65-7a2d6a804f3de137-01" file, err := os.CreateTemp(t.TempDir(), "go-test-otel-cli") if err != nil { t.Fatalf("unable to create tempfile for testing: %s", err) } defer os.Remove(file.Name()) // write in the full shell snippet format so that stripping gets tested // in this pass too file.WriteString("export TRACEPARENT=" + testFileTp) file.Close() // actually do the test... tp, err = LoadFromFile(file.Name()) if err != nil { t.Error("LoadFromFile returned an unexpected error: %w", err) } if tp.Encode() != testFileTp { t.Errorf("LoadFromFile failed, expected '%s', got '%s'", testFileTp, tp.Encode()) } // load from environment testEnvTp := "00-b122b620341449410b9cd900c96d459d-aa21cda35388b694-01" os.Setenv("TRACEPARENT", testEnvTp) tp, err = LoadFromEnv() if err != nil { t.Error("LoadFromEnv() returned an unexpected error: %w", err) } if tp.Encode() != testEnvTp { t.Errorf("LoadFromEnv() with envvar failed, expected '%s', got '%s'", testEnvTp, tp.Encode()) } } func TestWriteTraceparentToFile(t *testing.T) { testTp := "00-ce1c6ae29edafc52eb6dd223da7d20b4-1c617f036253531c-01" tp, err := Parse(testTp) if err != nil { t.Errorf("failed while parsing test TP %q: %s", testTp, err) } // create a tempfile for messing with file, err := os.CreateTemp(t.TempDir(), "go-test-otel-cli") if err != nil { t.Fatalf("unable to create tempfile for testing: %s", err) } file.Close() defer os.Remove(file.Name()) // not strictly necessary err = tp.SaveToFile(file.Name(), false) if err != nil { t.Error("SaveToFile returned an unexpected error: %w", err) } // read the data back, it should just be the traceparent string data, err := os.ReadFile(file.Name()) if err != nil { t.Fatalf("failed to read tempfile '%s': %s", file.Name(), err) } if len(data) == 0 { t.Errorf("saveTraceparentToFile wrote %d bytes to the tempfile, expected %d", len(data), len(testTp)) } // otel is non-recording in tests so the comments in the output will be zeroed // while the traceparent should come through just fine at the end of file if !strings.HasSuffix(strings.TrimSpace(string(data)), testTp) { t.Errorf("invalid data in traceparent file, expected '%s', got '%s'", testTp, data) } }