├── .backstage └── cloudrunner-go.yaml ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── release.yml │ └── stale.yml ├── .gitignore ├── .sage ├── go.mod ├── go.sum └── main.go ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── cloudclient ├── config.go ├── config_test.go ├── dial.go ├── dialinsecure.go ├── doc.go ├── middleware.go └── middleware_test.go ├── cloudconfig ├── config.go ├── doc.go ├── envconfig.go ├── options.go └── yaml.go ├── clouderror ├── wrap.go └── wrap_test.go ├── cloudmux ├── mux.go └── mux_test.go ├── cloudotel ├── doc.go ├── errorhandler.go ├── metricexporter.go ├── resource.go ├── traceexporter.go ├── traceidhook.go ├── tracemiddleware.go └── tracemiddleware_test.go ├── cloudprofiler ├── doc.go ├── profiler.go └── profiler_test.go ├── cloudpubsub ├── context.go ├── httphandler.go ├── httphandler_test.go └── payload.go ├── cloudrequestlog ├── additionalfields.go ├── codetolevel.go ├── config.go ├── details.go ├── doc.go ├── fullmethod.go ├── httpresponsewriter.go ├── logmessage.go ├── middleware.go └── migration.go ├── cloudruntime ├── config.go ├── doc.go ├── env.go ├── env_test.go ├── metadata.go ├── metadata_test.go ├── version.go └── version_test.go ├── cloudserver ├── config.go ├── doc.go ├── http.go ├── http_test.go ├── middleware.go ├── middleware_test.go └── security_headers.go ├── cloudslog ├── buildinfo.go ├── buildinfo_test.go ├── context.go ├── context_test.go ├── doc.go ├── errors.go ├── errors_test.go ├── handler.go ├── handler_test.go ├── httprequest.go ├── httprequest_test.go ├── proto.go ├── proto_test.go ├── redact.go ├── redact_test.go ├── resource.go └── resource_test.go ├── cloudstatus └── code.go ├── cloudstream └── middleware.go ├── cloudtesting ├── doc.go └── trace.go ├── cloudtrace ├── context.go ├── context_test.go ├── doc.go ├── exporter.go ├── idhook.go ├── metadata.go ├── metadata_test.go └── middleware.go ├── cloudzap ├── context.go ├── doc.go ├── encoderconfig.go ├── errorreport.go ├── httprequest.go ├── level.go ├── logger.go ├── middleware.go ├── proto.go ├── proto_test.go ├── resource.go ├── resource_test.go ├── sourcelocation.go ├── trace.go └── trace_test.go ├── dialservice.go ├── doc.go ├── examples └── cmd │ ├── additional-config │ ├── example.yaml │ └── main.go │ ├── grpc-server │ └── main.go │ └── http-server │ └── main.go ├── go.mod ├── go.sum ├── grpcserver.go ├── httpserver.go ├── logger.go ├── muxserver.go ├── options.go ├── pubsubhttphandler.go ├── run.go ├── run_test.go ├── runtime.go ├── trace.go ├── usage.go ├── wrap.go └── wrap_test.go /.backstage/cloudrunner-go.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: backstage.io/v1alpha1 2 | kind: Component 3 | metadata: 4 | name: cloudrunner-go 5 | title: Cloud Runner Go 6 | description: A lightweight, opinionated, batteries-included service SDK for running Go and gRPC in GCP. 7 | spec: 8 | type: go-library 9 | lifecycle: production 10 | owner: platform-engineering 11 | system: backend-sdk 12 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @einride/platform-engineering @einride/open-source-maintainers 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: / 6 | schedule: 7 | interval: monthly 8 | 9 | - package-ecosystem: gomod 10 | directory: / 11 | schedule: 12 | interval: monthly 13 | groups: 14 | opentelemetry: 15 | patterns: ["*opentelemetry*"] 16 | all: 17 | patterns: ["*"] 18 | 19 | - package-ecosystem: gomod 20 | directory: .sage 21 | schedule: 22 | interval: monthly 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened, synchronize] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Setup Sage 12 | uses: einride/sage/actions/setup@master 13 | with: 14 | go-version: 1.23 15 | 16 | - name: Make 17 | run: make 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | permissions: write-all 8 | 9 | jobs: 10 | release: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Setup Sage 14 | uses: einride/sage/actions/setup@master 15 | with: 16 | go-version: 1.23 17 | 18 | - name: Make 19 | run: make 20 | 21 | - name: Release 22 | uses: go-semantic-release/action@v1.24 23 | with: 24 | github-token: ${{ secrets.GITHUB_TOKEN }} 25 | allow-initial-development-versions: true 26 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Stale 2 | 3 | permissions: 4 | issues: write 5 | pull-requests: write 6 | 7 | on: 8 | workflow_dispatch: 9 | schedule: 10 | # At 08:00 on every Monday. 11 | - cron: "0 8 * * 1" 12 | 13 | jobs: 14 | stale: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/stale@v9 18 | with: 19 | days-before-pr-stale: 180 20 | days-before-pr-close: 14 21 | stale-pr-message: "This PR has been open for **180 days** with no activity. Remove the stale label or add a comment or it will be closed in **14 days**." 22 | days-before-issue-stale: 365 23 | days-before-issue-close: -1 24 | stale-issue-message: "This issue has been open for **365 days** with no activity. Marking as stale." 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .idea 3 | -------------------------------------------------------------------------------- /.sage/go.mod: -------------------------------------------------------------------------------- 1 | module go.einride.tech/cloudrunner/.sage 2 | 3 | go 1.23 4 | 5 | require go.einride.tech/sage v0.364.0 6 | -------------------------------------------------------------------------------- /.sage/go.sum: -------------------------------------------------------------------------------- 1 | go.einride.tech/sage v0.364.0 h1:zDXxZXdjhcswgkp/vecKuO5sKaIdcDLcUl+mKZw+nLk= 2 | go.einride.tech/sage v0.364.0/go.mod h1:sy9YuK//XVwEZ2wD3f19xVSKEtN8CYtgtBZGpzC3p80= 3 | -------------------------------------------------------------------------------- /.sage/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "regexp" 8 | "strings" 9 | 10 | "go.einride.tech/sage/sg" 11 | "go.einride.tech/sage/tools/sgconvco" 12 | "go.einride.tech/sage/tools/sggit" 13 | "go.einride.tech/sage/tools/sggo" 14 | "go.einride.tech/sage/tools/sggolangcilint" 15 | "go.einride.tech/sage/tools/sggolicenses" 16 | "go.einride.tech/sage/tools/sgmdformat" 17 | "go.einride.tech/sage/tools/sgyamlfmt" 18 | ) 19 | 20 | func main() { 21 | sg.GenerateMakefiles( 22 | sg.Makefile{ 23 | Path: sg.FromGitRoot("Makefile"), 24 | DefaultTarget: All, 25 | }, 26 | ) 27 | } 28 | 29 | func All(ctx context.Context) error { 30 | sg.Deps(ctx, ConvcoCheck, FormatMarkdown, FormatYAML, ReadmeSnippet) 31 | sg.Deps(ctx, GoLint, GoTest) 32 | sg.Deps(ctx, GoModTidy) 33 | sg.Deps(ctx, GitVerifyNoDiff) 34 | return nil 35 | } 36 | 37 | func FormatYAML(ctx context.Context) error { 38 | sg.Logger(ctx).Println("formatting YAML files...") 39 | return sgyamlfmt.Run(ctx) 40 | } 41 | 42 | func GoModTidy(ctx context.Context) error { 43 | sg.Logger(ctx).Println("tidying Go module files...") 44 | return sg.Command(ctx, "go", "mod", "tidy", "-v").Run() 45 | } 46 | 47 | func GoTest(ctx context.Context) error { 48 | sg.Logger(ctx).Println("running Go tests...") 49 | return sggo.TestCommand(ctx).Run() 50 | } 51 | 52 | func GoLint(ctx context.Context) error { 53 | sg.Logger(ctx).Println("linting Go files...") 54 | // It is currently not possible to ignore package deprecation lint errors so we ignore 55 | // it through flags and match the lint error string. 56 | // See https://github.com/golangci/golangci-lint/issues/741#issuecomment-1721737130 for more details. 57 | return sggolangcilint.Run(ctx, "--exclude", `SA1019: .go.einride.tech/cloudrunner/cloudtrace. is deprecated:`) 58 | } 59 | 60 | func GoLintFix(ctx context.Context) error { 61 | sg.Logger(ctx).Println("fixing Go files...") 62 | return sggolangcilint.Fix(ctx) 63 | } 64 | 65 | // TODO: Add this to All target once it is working again. 66 | func GoLicenses(ctx context.Context) error { 67 | sg.Logger(ctx).Println("checking Go licenses...") 68 | return sggolicenses.Check(ctx) 69 | } 70 | 71 | func FormatMarkdown(ctx context.Context) error { 72 | sg.Logger(ctx).Println("formatting Markdown files...") 73 | return sgmdformat.Command(ctx).Run() 74 | } 75 | 76 | func ConvcoCheck(ctx context.Context) error { 77 | sg.Logger(ctx).Println("checking git commits...") 78 | return sgconvco.Command(ctx, "check", "origin/master..HEAD").Run() 79 | } 80 | 81 | func GitVerifyNoDiff(ctx context.Context) error { 82 | sg.Logger(ctx).Println("verifying that git has no diff...") 83 | return sggit.VerifyNoDiff(ctx) 84 | } 85 | 86 | func ReadmeSnippet(ctx context.Context) error { 87 | usage := sg.Output(sg.Command(ctx, "go", "run", "./examples/cmd/grpc-server", "-help")) 88 | usage = strings.TrimSpace(usage) 89 | usage = "\n\n```\n" + usage 90 | usage += "\n```\n\n" 91 | readme, err := os.ReadFile("README.md") 92 | if err != nil { 93 | return err 94 | } 95 | usageRegexp := regexp.MustCompile(`(?ms).*`) 96 | if !usageRegexp.Match(readme) { 97 | return fmt.Errorf("found no match for 'usage' snippet in README.md") 98 | } 99 | return os.WriteFile("README.md", usageRegexp.ReplaceAll(readme, []byte(usage)), 0o600) 100 | } 101 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | open-source@einride.tech. 64 | 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the 119 | [Contributor Covenant](https://www.contributor-covenant.org), version 2.0, 120 | available at 121 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 122 | 123 | Community Impact Guidelines were inspired by 124 | [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Einride AB 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Code generated by go.einride.tech/sage. DO NOT EDIT. 2 | # To learn more, see .sage/main.go and https://github.com/einride/sage. 3 | 4 | .DEFAULT_GOAL := all 5 | 6 | cwd := $(dir $(realpath $(firstword $(MAKEFILE_LIST)))) 7 | sagefile := $(abspath $(cwd)/.sage/bin/sagefile) 8 | 9 | # Setup Go. 10 | go := $(shell command -v go 2>/dev/null) 11 | export GOWORK ?= off 12 | ifndef go 13 | SAGE_GO_VERSION ?= 1.23.4 14 | export GOROOT := $(abspath $(cwd)/.sage/tools/go/$(SAGE_GO_VERSION)/go) 15 | export PATH := $(PATH):$(GOROOT)/bin 16 | go := $(GOROOT)/bin/go 17 | os := $(shell uname | tr '[:upper:]' '[:lower:]') 18 | arch := $(shell uname -m) 19 | ifeq ($(arch),x86_64) 20 | arch := amd64 21 | endif 22 | $(go): 23 | $(info installing Go $(SAGE_GO_VERSION)...) 24 | @mkdir -p $(dir $(GOROOT)) 25 | @curl -sSL https://go.dev/dl/go$(SAGE_GO_VERSION).$(os)-$(arch).tar.gz | tar xz -C $(dir $(GOROOT)) 26 | @touch $(GOROOT)/go.mod 27 | @chmod +x $(go) 28 | endif 29 | 30 | .PHONY: $(sagefile) 31 | $(sagefile): $(go) 32 | @cd .sage && $(go) mod tidy && $(go) run . 33 | 34 | .PHONY: sage 35 | sage: 36 | @$(MAKE) $(sagefile) 37 | 38 | .PHONY: update-sage 39 | update-sage: $(go) 40 | @cd .sage && $(go) get go.einride.tech/sage@latest && $(go) mod tidy && $(go) run . 41 | 42 | .PHONY: clean-sage 43 | clean-sage: 44 | @git clean -fdx .sage/tools .sage/bin .sage/build 45 | 46 | .PHONY: all 47 | all: $(sagefile) 48 | @$(sagefile) All 49 | 50 | .PHONY: convco-check 51 | convco-check: $(sagefile) 52 | @$(sagefile) ConvcoCheck 53 | 54 | .PHONY: format-markdown 55 | format-markdown: $(sagefile) 56 | @$(sagefile) FormatMarkdown 57 | 58 | .PHONY: format-yaml 59 | format-yaml: $(sagefile) 60 | @$(sagefile) FormatYAML 61 | 62 | .PHONY: git-verify-no-diff 63 | git-verify-no-diff: $(sagefile) 64 | @$(sagefile) GitVerifyNoDiff 65 | 66 | .PHONY: go-licenses 67 | go-licenses: $(sagefile) 68 | @$(sagefile) GoLicenses 69 | 70 | .PHONY: go-lint 71 | go-lint: $(sagefile) 72 | @$(sagefile) GoLint 73 | 74 | .PHONY: go-lint-fix 75 | go-lint-fix: $(sagefile) 76 | @$(sagefile) GoLintFix 77 | 78 | .PHONY: go-mod-tidy 79 | go-mod-tidy: $(sagefile) 80 | @$(sagefile) GoModTidy 81 | 82 | .PHONY: go-test 83 | go-test: $(sagefile) 84 | @$(sagefile) GoTest 85 | 86 | .PHONY: readme-snippet 87 | readme-snippet: $(sagefile) 88 | @$(sagefile) ReadmeSnippet 89 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Einride welcomes feedback from security researchers and the general public to 4 | help improve our security. If you believe you have discovered a vulnerability, 5 | privacy issue, exposed data, or other security issues in relation to this 6 | project, we want to hear from you. This policy outlines steps for reporting 7 | security issues to us, what we expect, and what you can expect from us. 8 | 9 | ## Supported versions 10 | 11 | We release patches for security issues according to semantic versioning. This 12 | project is currently unstable (v0.x) and only the latest version will receive 13 | security patches. 14 | 15 | ## Reporting a vulnerability 16 | 17 | Please do not report security vulnerabilities through public issues, 18 | discussions, or change requests. 19 | 20 | Please report security issues via [oss-security@einride.tech][email]. Provide 21 | all relevant information, including steps to reproduce the issue, any affected 22 | versions, and known mitigations. The more details you provide, the easier it 23 | will be for us to triage and fix the issue. You will receive a response from us 24 | within 2 business days. If the issue is confirmed, a patch will be released as 25 | soon as possible. 26 | 27 | For more information, or security issues not relating to open source code, 28 | please consult our [Vulnerability Disclosure Policy][vdp]. 29 | 30 | ## Preferred languages 31 | 32 | English is our preferred language of communication. 33 | 34 | ## Contributions and recognition 35 | 36 | We appreciate every contribution and will do our best to publicly 37 | [acknowledge][acknowledgments] your contributions. 38 | 39 | [acknowledgments]: https://einride.tech/security-acknowledgments.txt 40 | [email]: mailto:oss-security@einride.tech 41 | [vdp]: https://www.einride.tech/vulnerability-disclosure-policy 42 | -------------------------------------------------------------------------------- /cloudclient/config.go: -------------------------------------------------------------------------------- 1 | package cloudclient 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "google.golang.org/grpc/codes" 10 | ) 11 | 12 | // Config configures a gRPC client's default timeout and retry behavior. 13 | // See: https://github.com/grpc/grpc-proto/blob/master/grpc/service_config/service_config.proto 14 | type Config struct { 15 | // The timeout of outgoing gRPC method calls. Set to zero to disable. 16 | Timeout time.Duration `default:"10s"` 17 | // Retry config. 18 | Retry RetryConfig 19 | } 20 | 21 | // RetryConfig configures default retry behavior for outgoing gRPC client calls. 22 | // See: https://github.com/grpc/grpc-proto/blob/master/grpc/service_config/service_config.proto 23 | type RetryConfig struct { 24 | // Enabled indicates if retries are enabled. 25 | Enabled bool `default:"true"` 26 | // InitialBackoff is the initial exponential backoff duration. 27 | // 28 | // The initial retry attempt will occur at: 29 | // random(0, initial_backoff). 30 | // 31 | // In general, the nth attempt will occur at: 32 | // random(0, min(initial_backoff*backoff_multiplier**(n-1), max_backoff)). 33 | // 34 | // Must be greater than zero. 35 | InitialBackoff time.Duration `default:"200ms"` 36 | // MaxBackoff is the maximum duration between retries. 37 | MaxBackoff time.Duration `default:"60s"` 38 | // MaxAttempts is the max number of backoff attempts retried. 39 | MaxAttempts int `default:"5"` 40 | // BackoffMultiplier is the exponential backoff multiplier. 41 | BackoffMultiplier float64 `default:"2"` 42 | // RetryableStatusCodes is the set of status codes which may be retried. 43 | // Unknown status codes are retried by default for the sake of retrying Google Cloud HTTP load balancer errors. 44 | RetryableStatusCodes []codes.Code `default:"Unavailable,Unknown"` 45 | } 46 | 47 | // AsServiceConfigJSON returns the default method call config as a valid gRPC service JSON config. 48 | func (c *Config) AsServiceConfigJSON() string { 49 | type methodNameJSON struct { 50 | Service string `json:"service"` 51 | Method string `json:"method"` 52 | } 53 | type retryPolicyJSON struct { 54 | MaxAttempts int `json:"maxAttempts"` 55 | MaxBackoff string `json:"maxBackoff"` 56 | InitialBackoff string `json:"initialBackoff"` 57 | BackoffMultiplier float64 `json:"backoffMultiplier"` 58 | RetryableStatusCodes []string `json:"retryableStatusCodes"` 59 | } 60 | type methodConfigJSON struct { 61 | Name []methodNameJSON `json:"name"` 62 | Timeout *string `json:"timeout,omitempty"` 63 | RetryPolicy *retryPolicyJSON `json:"retryPolicy,omitempty"` 64 | } 65 | type serviceConfigJSON struct { 66 | MethodConfig []methodConfigJSON `json:"methodConfig"` 67 | } 68 | var s strings.Builder 69 | methodConfig := methodConfigJSON{ 70 | Name: []methodNameJSON{ 71 | {}, // no service or method specified means the config applies to all methods, all services. 72 | }, 73 | } 74 | if c.Timeout > 0 { 75 | methodConfig.Timeout = new(string) 76 | *methodConfig.Timeout = fmt.Sprintf("%gs", c.Timeout.Seconds()) 77 | } 78 | if c.Retry.Enabled { 79 | methodConfig.RetryPolicy = &retryPolicyJSON{ 80 | MaxAttempts: c.Retry.MaxAttempts, 81 | InitialBackoff: fmt.Sprintf("%gs", c.Retry.InitialBackoff.Seconds()), 82 | MaxBackoff: fmt.Sprintf("%gs", c.Retry.MaxBackoff.Seconds()), 83 | BackoffMultiplier: c.Retry.BackoffMultiplier, 84 | RetryableStatusCodes: make([]string, 0, len(c.Retry.RetryableStatusCodes)), 85 | } 86 | for _, code := range c.Retry.RetryableStatusCodes { 87 | methodConfig.RetryPolicy.RetryableStatusCodes = append( 88 | methodConfig.RetryPolicy.RetryableStatusCodes, strings.ToUpper(code.String()), 89 | ) 90 | } 91 | } 92 | if err := json.NewEncoder(&s).Encode(serviceConfigJSON{ 93 | MethodConfig: []methodConfigJSON{methodConfig}, 94 | }); err != nil { 95 | panic(err) 96 | } 97 | return strings.TrimSpace(s.String()) 98 | } 99 | -------------------------------------------------------------------------------- /cloudclient/config_test.go: -------------------------------------------------------------------------------- 1 | package cloudclient 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "google.golang.org/grpc/codes" 8 | "gotest.tools/v3/assert" 9 | ) 10 | 11 | func TestClientConfig_AsServiceConfigJSON(t *testing.T) { 12 | input := Config{ 13 | Timeout: 5 * time.Second, 14 | Retry: RetryConfig{ 15 | Enabled: true, 16 | InitialBackoff: 200 * time.Millisecond, 17 | MaxBackoff: 3 * time.Second, 18 | MaxAttempts: 5, 19 | BackoffMultiplier: 2, 20 | RetryableStatusCodes: []codes.Code{ 21 | codes.Unavailable, 22 | codes.Unknown, 23 | }, 24 | }, 25 | } 26 | const expected = `{"methodConfig":[{"name":[{"service":"","method":""}],"timeout":"5s",` + 27 | `"retryPolicy":{"maxAttempts":5,"maxBackoff":"3s","initialBackoff":"0.2s","backoffMultiplier":2,` + 28 | `"retryableStatusCodes":["UNAVAILABLE","UNKNOWN"]}}]}` 29 | assert.Equal(t, expected, input.AsServiceConfigJSON()) 30 | } 31 | -------------------------------------------------------------------------------- /cloudclient/dial.go: -------------------------------------------------------------------------------- 1 | package cloudclient 2 | 3 | import ( 4 | "context" 5 | "crypto/x509" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "golang.org/x/oauth2" 12 | "google.golang.org/api/idtoken" 13 | "google.golang.org/api/option" 14 | "google.golang.org/grpc" 15 | "google.golang.org/grpc/credentials" 16 | "google.golang.org/grpc/credentials/oauth" 17 | "google.golang.org/grpc/keepalive" 18 | ) 19 | 20 | // DialService dials another Cloud Run gRPC service with the default service account's RPC credentials. 21 | func DialService(ctx context.Context, target string, opts ...grpc.DialOption) (*grpc.ClientConn, error) { 22 | tokenSource, err := newTokenSource(ctx, target) 23 | if err != nil { 24 | return nil, err 25 | } 26 | systemCertPool, err := x509.SystemCertPool() 27 | if err != nil { 28 | return nil, fmt.Errorf("dial %s: %w", target, err) 29 | } 30 | defaultOpts := []grpc.DialOption{ 31 | grpc.WithPerRPCCredentials(&oauth.TokenSource{TokenSource: tokenSource}), 32 | grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(systemCertPool, "")), 33 | // Enable connection keepalive to mitigate "connection reset by peer". 34 | // https://cloud.google.com/run/docs/troubleshooting 35 | // For details on keepalive settings, see: 36 | // https://github.com/grpc/grpc-go/blob/master/Documentation/keepalive.md 37 | grpc.WithKeepaliveParams(keepalive.ClientParameters{ 38 | Time: 1 * time.Minute, 39 | Timeout: 10 * time.Second, 40 | PermitWithoutStream: true, 41 | }), 42 | } 43 | conn, err := grpc.NewClient(withDefaultPort(target, 443), append(defaultOpts, opts...)...) 44 | if err != nil { 45 | return nil, fmt.Errorf("dial %s: %w", target, err) 46 | } 47 | return conn, nil 48 | } 49 | 50 | func trimPort(target string) string { 51 | parts := strings.Split(target, ":") 52 | if len(parts) == 1 { 53 | return target 54 | } 55 | return strings.Join(parts[:len(parts)-1], ":") 56 | } 57 | 58 | func withDefaultPort(target string, port int) string { 59 | parts := strings.Split(target, ":") 60 | if len(parts) == 1 { 61 | return target + ":" + strconv.Itoa(port) 62 | } 63 | return target 64 | } 65 | 66 | func newTokenSource(ctx context.Context, target string) (_ oauth2.TokenSource, err error) { 67 | defer func() { 68 | if err != nil { 69 | err = fmt.Errorf("new token source: %w", err) 70 | } 71 | }() 72 | audience := "https://" + trimPort(target) 73 | idTokenSource, err := idtoken.NewTokenSource(ctx, audience, option.WithAudiences(audience)) 74 | return idTokenSource, err 75 | } 76 | -------------------------------------------------------------------------------- /cloudclient/dialinsecure.go: -------------------------------------------------------------------------------- 1 | package cloudclient 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | 8 | "cloud.google.com/go/compute/metadata" 9 | "golang.org/x/oauth2" 10 | "google.golang.org/api/idtoken" 11 | "google.golang.org/api/option" 12 | "google.golang.org/grpc" 13 | "google.golang.org/grpc/credentials/insecure" 14 | ) 15 | 16 | // DialServiceInsecure establishes an insecure connection to another service that must be on the local host. 17 | // Only works outside of GCE, and fails if attempting to dial any other host than localhost. 18 | // Should never be used in production code, only for debugging and local development. 19 | func DialServiceInsecure(ctx context.Context, target string, opts ...grpc.DialOption) (*grpc.ClientConn, error) { 20 | if metadata.OnGCE() { 21 | return nil, fmt.Errorf("dial insecure: forbidden on GCE") 22 | } 23 | parsedTarget, err := url.Parse(target) 24 | if err != nil { 25 | return nil, fmt.Errorf("dial insecure '%s': %w", target, err) 26 | } 27 | if parsedTarget.Hostname() != "localhost" { 28 | return nil, fmt.Errorf("dial insecure '%s': only allowed for localhost", target) 29 | } 30 | const audience = "http://localhost" 31 | idTokenSource, err := idtoken.NewTokenSource(ctx, audience, option.WithAudiences(audience)) 32 | if err != nil { 33 | return nil, fmt.Errorf("dial insecure '%s': %w", target, err) 34 | } 35 | defaultOpts := []grpc.DialOption{ 36 | grpc.WithPerRPCCredentials(insecureTokenSource{TokenSource: idTokenSource}), 37 | grpc.WithTransportCredentials(insecure.NewCredentials()), 38 | } 39 | conn, err := grpc.NewClient(parsedTarget.Host, append(defaultOpts, opts...)...) 40 | if err != nil { 41 | return nil, fmt.Errorf("dial insecure '%s': %w", target, err) 42 | } 43 | return conn, nil 44 | } 45 | 46 | type insecureTokenSource struct { 47 | oauth2.TokenSource 48 | } 49 | 50 | func (ts insecureTokenSource) GetRequestMetadata(context.Context, ...string) (map[string]string, error) { 51 | token, err := ts.Token() 52 | if err != nil { 53 | return nil, err 54 | } 55 | return map[string]string{"authorization": token.Type() + " " + token.AccessToken}, nil 56 | } 57 | 58 | func (insecureTokenSource) RequireTransportSecurity() bool { 59 | return false 60 | } 61 | -------------------------------------------------------------------------------- /cloudclient/doc.go: -------------------------------------------------------------------------------- 1 | // Package cloudclient provides primitives for gRPC clients. 2 | package cloudclient 3 | -------------------------------------------------------------------------------- /cloudclient/middleware.go: -------------------------------------------------------------------------------- 1 | package cloudclient 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strconv" 7 | "strings" 8 | 9 | "google.golang.org/grpc" 10 | "google.golang.org/grpc/codes" 11 | "google.golang.org/grpc/status" 12 | ) 13 | 14 | type Middleware struct{} 15 | 16 | // GRPCUnaryClientInterceptor adds standard middleware for gRPC clients. 17 | func (l *Middleware) GRPCUnaryClientInterceptor( 18 | ctx context.Context, 19 | fullMethod string, 20 | request interface{}, 21 | response interface{}, 22 | cc *grpc.ClientConn, 23 | invoker grpc.UnaryInvoker, 24 | opts ...grpc.CallOption, 25 | ) error { 26 | return handleHTTPResponseToGRPCRequest(invoker(ctx, fullMethod, request, response, cc, opts...)) 27 | } 28 | 29 | func handleHTTPResponseToGRPCRequest(errInput error) error { 30 | if errInput == nil { 31 | return nil 32 | } 33 | // When grpc-go encounters http content type text/html it will not 34 | // forward any actionable fields for us to look at. Instead we resort 35 | // to matching strings. 36 | // 37 | // These strings are coming from: 38 | // * "google.golang.org/grpc/internal/transport/http_util.go". 39 | // * "google.golang.org/grpc/internal/transport/http2_client.go". 40 | errStatus := status.Convert(errInput) 41 | if errStatus.Code() != codes.Unknown { 42 | return errInput 43 | } 44 | errorMessage := errStatus.Message() 45 | if !isContentTypeHTML(errorMessage) { 46 | return errInput 47 | } 48 | if isStatusCode(errorMessage, http.StatusForbidden) { 49 | // This happens when the gRPC request got rejected due to missing IAM permissions. 50 | // The request gets rejected at the HTTP level and a gRPC error will not be available. 51 | return status.Errorf( 52 | codes.PermissionDenied, 53 | "the gRPC request failed with a HTTP 403 error "+ 54 | "(on Google Cloud this happens when the client service account does not have IAM permissions "+ 55 | "to call the remote service - "+ 56 | "on Cloud Run, the client service account must have roles/run.invoker on the remote service): %v", 57 | errorMessage, 58 | ) 59 | } 60 | // Other HTTP responses to gRPC requests are assumed to be transient. 61 | return status.Error(codes.Unavailable, errorMessage) 62 | } 63 | 64 | func isContentTypeHTML(msg string) bool { 65 | return strings.Contains(msg, "transport") && strings.Contains(msg, `content-type "text/html`) 66 | } 67 | 68 | func isStatusCode(msg string, statusCode int) bool { 69 | return strings.Contains(msg, strconv.Itoa(statusCode)) 70 | } 71 | -------------------------------------------------------------------------------- /cloudclient/middleware_test.go: -------------------------------------------------------------------------------- 1 | package cloudclient 2 | 3 | import ( 4 | "testing" 5 | 6 | "google.golang.org/grpc/codes" 7 | "google.golang.org/grpc/status" 8 | "gotest.tools/v3/assert" 9 | ) 10 | 11 | func TestHandleHTTPResponseToGRPCRequest(t *testing.T) { 12 | for _, tt := range []struct { 13 | name string 14 | err error 15 | expected error 16 | }{ 17 | { 18 | name: "gRPC NotFound", 19 | err: status.Error( 20 | codes.NotFound, 21 | "not found", 22 | ), 23 | expected: status.Error( 24 | codes.NotFound, 25 | "not found", 26 | ), 27 | }, 28 | 29 | { 30 | name: "HTTP 200 OK", 31 | err: status.Error( 32 | codes.Unknown, 33 | "OK: HTTP status code 200; transport: received the unexpected content-type \"text/html\"", 34 | ), 35 | expected: status.Error( 36 | codes.Unavailable, 37 | "OK: HTTP status code 200; transport: received the unexpected content-type \"text/html\"", 38 | ), 39 | }, 40 | 41 | { 42 | name: "HTTP 500 Internal Server Error", 43 | err: status.Error( 44 | codes.Unknown, 45 | "rpc error: code = Unknown desc = Internal Server Error: HTTP status code 500; "+ 46 | "transport: received the unexpected content-type \"text/html; charset=UTF-8\"", 47 | ), 48 | expected: status.Error( 49 | codes.Unavailable, 50 | "rpc error: code = Unknown desc = Internal Server Error: HTTP status code 500; "+ 51 | "transport: received the unexpected content-type \"text/html; charset=UTF-8\"", 52 | ), 53 | }, 54 | { 55 | name: "HTTP 500 Internal Server Error", 56 | err: status.Error( 57 | codes.Unknown, 58 | "unexpected HTTP status code received from server: 500 (Internal Server Error); "+ 59 | "transport: received unexpected content-type \"text/html; charset=UTF-8\"", 60 | ), 61 | expected: status.Error( 62 | codes.Unavailable, 63 | "unexpected HTTP status code received from server: 500 (Internal Server Error); "+ 64 | "transport: received unexpected content-type \"text/html; charset=UTF-8\"", 65 | ), 66 | }, 67 | 68 | { 69 | name: "HTTP 403 Forbidden", 70 | err: status.Error( 71 | codes.Unknown, 72 | "Forbidden: HTTP status code 403; transport: received the unexpected content-type \"text/html\"", 73 | ), 74 | expected: status.Error( 75 | codes.PermissionDenied, 76 | "the gRPC request failed with a HTTP 403 error (on Google Cloud this happens when the client "+ 77 | "service account does not have IAM permissions to call the remote service - on Cloud Run, "+ 78 | "the client service account must have roles/run.invoker on the remote service): "+ 79 | "Forbidden: HTTP status code 403; transport: received the unexpected content-type \"text/html\"", 80 | ), 81 | }, 82 | { 83 | name: "HTTP 403 Forbidden", 84 | err: status.Error( 85 | codes.Unknown, 86 | "unexpected HTTP status code received from server: 403 (Forbidden); "+ 87 | "transport: received unexpected content-type \"text/html; charset=UTF-8\"", 88 | ), 89 | expected: status.Error( 90 | codes.PermissionDenied, 91 | "the gRPC request failed with a HTTP 403 error (on Google Cloud this happens when the client "+ 92 | "service account does not have IAM permissions to call the remote service - on Cloud Run, "+ 93 | "the client service account must have roles/run.invoker on the remote service): "+ 94 | "unexpected HTTP status code received from server: 403 (Forbidden); "+ 95 | "transport: received unexpected content-type \"text/html; charset=UTF-8\"", 96 | ), 97 | }, 98 | } { 99 | t.Run(tt.name, func(t *testing.T) { 100 | actual := handleHTTPResponseToGRPCRequest(tt.err) 101 | assert.Equal(t, status.Code(actual), status.Code(tt.expected)) 102 | assert.Equal(t, status.Convert(actual).Message(), status.Convert(tt.expected).Message()) 103 | }) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /cloudconfig/config.go: -------------------------------------------------------------------------------- 1 | package cloudconfig 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log/slog" 7 | "os" 8 | "text/tabwriter" 9 | "time" 10 | 11 | "go.opentelemetry.io/otel/codes" 12 | ) 13 | 14 | // envPrefix can be set during build-time to append a prefix to all environment variables loaded into the RunConfig. 15 | // 16 | //nolint:gochecknoglobals 17 | var envPrefix string 18 | 19 | // New creates a new Config with the provided name, specification and options. 20 | func New(name string, spec interface{}, options ...Option) (*Config, error) { 21 | config := Config{ 22 | configSpecs: []*configSpec{ 23 | {name: name, spec: spec}, 24 | }, 25 | envPrefix: envPrefix, 26 | } 27 | for _, option := range options { 28 | option(&config) 29 | } 30 | for _, configSpec := range config.configSpecs { 31 | fieldSpecs, err := collectFieldSpecs(config.envPrefix, configSpec.spec) 32 | if err != nil { 33 | return nil, err 34 | } 35 | configSpec.fieldSpecs = fieldSpecs 36 | } 37 | return &config, nil 38 | } 39 | 40 | // Config is a config. 41 | type Config struct { 42 | configSpecs []*configSpec 43 | envPrefix string 44 | yamlServiceSpecificationFilename string 45 | optionalSecrets bool 46 | } 47 | 48 | type configSpec struct { 49 | name string 50 | spec interface{} 51 | fieldSpecs []fieldSpec 52 | } 53 | 54 | // Load values into the config. 55 | func (c *Config) Load() error { 56 | if c.yamlServiceSpecificationFilename != "" { 57 | envs, err := getEnvFromYAMLServiceSpecificationFile(c.yamlServiceSpecificationFilename) 58 | if err != nil { 59 | return err 60 | } 61 | if err := validateEnvSecretTags(envs, c.configSpecs); err != nil { 62 | return err 63 | } 64 | for _, e := range envs { 65 | if err := os.Setenv(e.Name, e.Value); err != nil { 66 | return err 67 | } 68 | } 69 | } 70 | for _, cs := range c.configSpecs { 71 | if err := c.process(cs.fieldSpecs); err != nil { 72 | return err 73 | } 74 | } 75 | return nil 76 | } 77 | 78 | func validateEnvSecretTags(envs []env, configSpecs []*configSpec) error { 79 | for _, env := range envs { 80 | if env.ValueFrom.SecretKeyRef.Key == "" && env.ValueFrom.SecretKeyRef.Name == "" { 81 | continue 82 | } 83 | for _, spec := range configSpecs { 84 | for _, f := range spec.fieldSpecs { 85 | if f.Key == env.Name { 86 | if !f.Secret { 87 | return fmt.Errorf("field %s does not have the correct secret tag", f.Name) 88 | } 89 | } 90 | } 91 | } 92 | } 93 | return nil 94 | } 95 | 96 | // PrintUsage prints usage of the config to the provided io.Writer. 97 | func (c *Config) PrintUsage(w io.Writer) { 98 | tabs := tabwriter.NewWriter(w, 1, 0, 4, ' ', 0) 99 | _, _ = fmt.Fprintf(tabs, "CONFIG\tENV\tTYPE\tDEFAULT\tON GCE\n") 100 | for _, cs := range c.configSpecs { 101 | for _, fs := range cs.fieldSpecs { 102 | _, _ = fmt.Fprintf( 103 | tabs, 104 | "%v\t%v\t%v\t%v\t%v\n", 105 | cs.name, 106 | fs.Key, 107 | fs.Value.Type(), 108 | fs.Tags.Get("default"), 109 | fs.Tags.Get("onGCE"), 110 | ) 111 | } 112 | } 113 | _ = tabs.Flush() 114 | } 115 | 116 | // LogValue implements [slog.LogValuer]. 117 | func (c *Config) LogValue() slog.Value { 118 | attrs := make([]slog.Attr, 0, len(c.configSpecs)) 119 | for _, configSpec := range c.configSpecs { 120 | attrs = append(attrs, slog.Any(configSpec.name, fieldSpecsValue(configSpec.fieldSpecs))) 121 | } 122 | return slog.GroupValue(attrs...) 123 | } 124 | 125 | type fieldSpecsValue []fieldSpec 126 | 127 | func (fsv fieldSpecsValue) LogValue() slog.Value { 128 | attrs := make([]slog.Attr, 0, len(fsv)) 129 | for _, fs := range fsv { 130 | if fs.Secret { 131 | attrs = append(attrs, slog.String(fs.Key, "")) 132 | continue 133 | } 134 | switch value := fs.Value.Interface().(type) { 135 | case time.Duration: 136 | attrs = append(attrs, slog.Duration(fs.Key, value)) 137 | case []codes.Code: 138 | logValue := make([]string, 0, len(value)) 139 | for _, code := range value { 140 | logValue = append(logValue, code.String()) 141 | } 142 | attrs = append(attrs, slog.Any(fs.Key, logValue)) 143 | case map[codes.Code]slog.Level: 144 | logValue := make(map[string]string, len(value)) 145 | for code, level := range value { 146 | logValue[code.String()] = level.String() 147 | } 148 | attrs = append(attrs, slog.Any(fs.Key, logValue)) 149 | default: 150 | attrs = append(attrs, slog.Any(fs.Key, fs.Value.Interface())) 151 | } 152 | } 153 | return slog.GroupValue(attrs...) 154 | } 155 | -------------------------------------------------------------------------------- /cloudconfig/doc.go: -------------------------------------------------------------------------------- 1 | // Package cloudconfig provides primitives for loading configuration. 2 | // Based on github.com/kelseyhightower/envconfig, with modifications. 3 | package cloudconfig 4 | -------------------------------------------------------------------------------- /cloudconfig/options.go: -------------------------------------------------------------------------------- 1 | package cloudconfig 2 | 3 | // Option is a configuration option. 4 | type Option func(*Config) 5 | 6 | // WithAdditionalSpec includes an additional specification in the config loading. 7 | func WithAdditionalSpec(name string, spec interface{}) Option { 8 | return func(config *Config) { 9 | config.configSpecs = append(config.configSpecs, &configSpec{ 10 | name: name, 11 | spec: spec, 12 | }) 13 | } 14 | } 15 | 16 | // WithEnvPrefix sets the environment prefix to use for config loading. 17 | func WithEnvPrefix(envPrefix string) Option { 18 | return func(config *Config) { 19 | config.envPrefix = envPrefix 20 | } 21 | } 22 | 23 | // WithYAMLServiceSpecificationFile sets the YAML service specification file to load environment variables from. 24 | func WithYAMLServiceSpecificationFile(filename string) Option { 25 | return func(config *Config) { 26 | config.yamlServiceSpecificationFilename = filename 27 | } 28 | } 29 | 30 | // WithOptionalSecrets overrides all secrets to be optional. 31 | func WithOptionalSecrets() Option { 32 | return func(config *Config) { 33 | config.optionalSecrets = true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /cloudconfig/yaml.go: -------------------------------------------------------------------------------- 1 | package cloudconfig 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | type env struct { 12 | Name string 13 | Value string 14 | ValueFrom struct { 15 | SecretKeyRef struct { 16 | Key string 17 | Name string 18 | } `yaml:"secretKeyRef"` 19 | } `yaml:"valueFrom"` 20 | } 21 | 22 | func getEnvFromYAMLServiceSpecificationFile(name string) (envValues []env, err error) { 23 | defer func() { 24 | if err != nil { 25 | err = fmt.Errorf("set env from YAML service/job specification file %s: %w", name, err) 26 | } 27 | }() 28 | var kind struct { 29 | Kind string 30 | } 31 | data, err := os.ReadFile(name) 32 | if err != nil { 33 | return nil, err 34 | } 35 | if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&kind); err != nil { 36 | return nil, err 37 | } 38 | var envs []env 39 | switch kind.Kind { 40 | case "Service": // Cloud Run Services 41 | var config struct { 42 | Metadata struct { 43 | Name string 44 | } 45 | Spec struct { 46 | Template struct { 47 | Spec struct { 48 | Containers []struct { 49 | Env []env 50 | } 51 | } 52 | } 53 | } 54 | } 55 | if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&config); err != nil { 56 | return nil, err 57 | } 58 | containers := config.Spec.Template.Spec.Containers 59 | if len(containers) == 0 || len(containers) > 10 { 60 | return nil, fmt.Errorf("unexpected number of containers: %d", len(containers)) 61 | } 62 | if config.Metadata.Name != "" { 63 | if err := os.Setenv("K_SERVICE", config.Metadata.Name); err != nil { 64 | return nil, err 65 | } 66 | } 67 | envs = containers[0].Env 68 | case "Job": // Cloud Run Jobs 69 | var config struct { 70 | Metadata struct { 71 | Name string 72 | } 73 | Spec struct { 74 | Template struct { 75 | Spec struct { 76 | Template struct { 77 | Spec struct { 78 | Containers []struct { 79 | Env []env 80 | } 81 | } 82 | } 83 | } 84 | } 85 | } 86 | } 87 | if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&config); err != nil { 88 | return nil, err 89 | } 90 | containers := config.Spec.Template.Spec.Template.Spec.Containers 91 | if len(containers) == 0 || len(containers) > 10 { 92 | return nil, fmt.Errorf("unexpected number of containers: %d", len(containers)) 93 | } 94 | if config.Metadata.Name != "" { 95 | if err := os.Setenv("K_SERVICE", config.Metadata.Name); err != nil { 96 | return nil, err 97 | } 98 | } 99 | envs = containers[0].Env 100 | default: 101 | return nil, fmt.Errorf("unknown config kind: %s", kind.Kind) 102 | } 103 | envValues = make([]env, 0, len(envs)) 104 | for _, env := range envs { 105 | // Prefer variables from local environment. 106 | if _, ok := os.LookupEnv(env.Name); !ok { 107 | envValues = append(envValues, env) 108 | } 109 | } 110 | return envValues, nil 111 | } 112 | -------------------------------------------------------------------------------- /clouderror/wrap.go: -------------------------------------------------------------------------------- 1 | package clouderror 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "runtime" 9 | "syscall" 10 | 11 | "golang.org/x/net/http2" 12 | "google.golang.org/grpc/codes" 13 | "google.golang.org/grpc/status" 14 | ) 15 | 16 | // Wrap masks the gRPC status of the provided error by replacing it with the provided status. 17 | func Wrap(err error, s *status.Status) error { 18 | return &wrappedStatusError{status: s, err: err, caller: NewCaller(runtime.Caller(1))} 19 | } 20 | 21 | // WrapCaller masks the gRPC status of the provided error by replacing it with the provided status. 22 | // The call site of the error is captured from the provided caller. 23 | func WrapCaller(err error, s *status.Status, caller Caller) error { 24 | return &wrappedStatusError{status: s, err: err, caller: caller} 25 | } 26 | 27 | // WrapTransient masks the gRPC status of the provided error by replacing the status message. 28 | // If the original error has transient (retryable) gRPC status code, the status code is forwarded. 29 | // Otherwise, the status code is masked with INTERNAL. 30 | func WrapTransient(err error, msg string) error { 31 | return WrapTransientCaller(err, msg, NewCaller(runtime.Caller(1))) 32 | } 33 | 34 | // WrapTransient masks the gRPC status of the provided error by replacing the status message. 35 | // If the original error has transient (retryable) gRPC status code, the status code is forwarded. 36 | // Otherwise, the status code is masked with INTERNAL. 37 | // The call site of the error is captured from the provided caller. 38 | func WrapTransientCaller(err error, msg string, caller Caller) error { 39 | if s, ok := status.FromError(err); ok { 40 | switch s.Code() { 41 | case codes.Unavailable, codes.DeadlineExceeded, codes.Canceled: 42 | return &wrappedStatusError{status: status.New(s.Code(), msg), err: err, caller: caller} 43 | } 44 | } 45 | switch { 46 | case errors.Is(err, context.DeadlineExceeded): 47 | return &wrappedStatusError{status: status.New(codes.DeadlineExceeded, msg), err: err, caller: caller} 48 | case errors.Is(err, context.Canceled): 49 | return &wrappedStatusError{status: status.New(codes.Canceled, msg), err: err, caller: caller} 50 | case errors.Is(err, syscall.ECONNRESET): 51 | return &wrappedStatusError{status: status.New(codes.Unavailable, msg), err: err, caller: caller} 52 | case errors.As(err, &http2.GoAwayError{}): 53 | return &wrappedStatusError{status: status.New(codes.Unavailable, msg), err: err, caller: caller} 54 | case os.IsTimeout(err): 55 | return &wrappedStatusError{status: status.New(codes.Unavailable, msg), err: err, caller: caller} 56 | default: 57 | return &wrappedStatusError{status: status.New(codes.Internal, msg), err: err, caller: caller} 58 | } 59 | } 60 | 61 | type wrappedStatusError struct { 62 | status *status.Status 63 | err error 64 | caller Caller 65 | } 66 | 67 | // Caller returns the error's caller. 68 | func (w *wrappedStatusError) Caller() (pc uintptr, file string, line int, ok bool) { 69 | return w.caller.pc, w.caller.file, w.caller.line, w.caller.ok 70 | } 71 | 72 | // String implements fmt.Stringer. 73 | func (w *wrappedStatusError) String() string { 74 | return w.Error() 75 | } 76 | 77 | // Error implements error. 78 | func (w *wrappedStatusError) Error() string { 79 | return fmt.Sprintf("%v: %s: %v", w.status.Code(), w.status.Message(), w.err) 80 | } 81 | 82 | // GRPCStatus returns the gRPC status of the wrapped error. 83 | func (w *wrappedStatusError) GRPCStatus() *status.Status { 84 | return w.status 85 | } 86 | 87 | // Unwrap implements error unwrapping. 88 | func (w *wrappedStatusError) Unwrap() error { 89 | return w.err 90 | } 91 | 92 | // Caller is the caller info for an error. 93 | type Caller struct { 94 | pc uintptr 95 | file string 96 | line int 97 | ok bool 98 | } 99 | 100 | // NewCaller creates a new caller. 101 | func NewCaller(pc uintptr, file string, line int, ok bool) Caller { 102 | return Caller{pc: pc, file: file, line: line, ok: ok} 103 | } 104 | -------------------------------------------------------------------------------- /clouderror/wrap_test.go: -------------------------------------------------------------------------------- 1 | package clouderror 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "testing" 8 | 9 | "golang.org/x/net/http2" 10 | "google.golang.org/grpc/codes" 11 | "google.golang.org/grpc/status" 12 | "gotest.tools/v3/assert" 13 | ) 14 | 15 | func Test_WrapTransient(t *testing.T) { 16 | t.Parallel() 17 | for _, tt := range []struct { 18 | name string 19 | err error 20 | expectedCode codes.Code 21 | }{ 22 | { 23 | name: "nil", 24 | err: nil, 25 | expectedCode: codes.Internal, 26 | }, 27 | { 28 | name: "codes.DeadlineExceeded", 29 | err: status.Error(codes.DeadlineExceeded, "transient"), 30 | expectedCode: codes.DeadlineExceeded, 31 | }, 32 | { 33 | name: "codes.Canceled", 34 | err: status.Error(codes.Canceled, "transient"), 35 | expectedCode: codes.Canceled, 36 | }, 37 | { 38 | name: "codes.Unavailable", 39 | err: status.Error(codes.Unavailable, "transient"), 40 | expectedCode: codes.Unavailable, 41 | }, 42 | { 43 | name: "wrapped transient", 44 | err: Wrap( 45 | fmt.Errorf("network unavailable"), 46 | status.New(codes.Unavailable, "bad"), 47 | ), 48 | expectedCode: codes.Unavailable, 49 | }, 50 | { 51 | name: "context.DeadlineExceeded", 52 | err: context.DeadlineExceeded, 53 | expectedCode: codes.DeadlineExceeded, 54 | }, 55 | { 56 | name: "context.Canceled", 57 | err: context.Canceled, 58 | expectedCode: codes.Canceled, 59 | }, 60 | { 61 | name: "wrapped context.Canceled", 62 | err: fmt.Errorf("bad: %w", context.Canceled), 63 | expectedCode: codes.Canceled, 64 | }, 65 | { 66 | name: "http2.GoAwayError", 67 | err: http2.GoAwayError{ 68 | LastStreamID: 123, 69 | ErrCode: http2.ErrCodeNo, 70 | DebugData: "deadbeef", 71 | }, 72 | expectedCode: codes.Unavailable, 73 | }, 74 | { 75 | name: "os timeout error", 76 | err: os.ErrDeadlineExceeded, 77 | expectedCode: codes.Unavailable, 78 | }, 79 | } { 80 | t.Run(tt.name, func(t *testing.T) { 81 | t.Parallel() 82 | got := WrapTransient(tt.err, "boom") 83 | assert.Equal(t, tt.expectedCode, status.Code(got), got) 84 | }) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /cloudmux/mux.go: -------------------------------------------------------------------------------- 1 | package cloudmux 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "net" 9 | "net/http" 10 | "strings" 11 | "time" 12 | 13 | "github.com/soheilhy/cmux" 14 | "golang.org/x/sync/errgroup" 15 | "google.golang.org/grpc" 16 | ) 17 | 18 | // ServeGRPCHTTP serves both a gRPC and an HTTP server on listener l. 19 | // When the context is canceled, the servers will be gracefully shutdown and 20 | // then the function will return. 21 | func ServeGRPCHTTP( 22 | ctx context.Context, 23 | l net.Listener, 24 | grpcServer *grpc.Server, 25 | httpServer *http.Server, 26 | ) error { 27 | m := cmux.New(l) 28 | grpcL := m.MatchWithWriters( 29 | cmux.HTTP2MatchHeaderFieldSendSettings("content-type", "application/grpc"), 30 | cmux.HTTP2MatchHeaderFieldSendSettings("content-type", "application/grpc+proto"), 31 | ) 32 | httpL := m.Match(cmux.Any()) 33 | var g errgroup.Group 34 | // wait for context to be canceled and gracefully stop all servers. 35 | g.Go(func() error { 36 | <-ctx.Done() 37 | slog.DebugContext(ctx, "stopping cmux server") 38 | m.Close() 39 | slog.DebugContext(ctx, "stopping HTTP server") 40 | // use a new context because the parent ctx is already canceled. 41 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) 42 | defer cancel() 43 | if err := httpServer.Shutdown(ctx); err != nil && !isClosedErr(err) { 44 | slog.WarnContext(ctx, "stopping http server", slog.Any("error", err)) 45 | } 46 | slog.DebugContext(ctx, "stopping gRPC server") 47 | grpcServer.GracefulStop() 48 | slog.DebugContext(ctx, "stopped both http and grpc server") 49 | return nil 50 | }) 51 | 52 | g.Go(func() error { 53 | slog.DebugContext(ctx, "serving gRPC") 54 | if err := grpcServer.Serve(grpcL); err != nil && !isClosedErr(err) { 55 | return fmt.Errorf("serve gRPC: %w", err) 56 | } 57 | slog.DebugContext(ctx, "stopped serving gRPC") 58 | return nil 59 | }) 60 | 61 | g.Go(func() error { 62 | slog.DebugContext(ctx, "serving HTTP") 63 | if err := httpServer.Serve(httpL); err != nil && !isClosedErr(err) { 64 | return fmt.Errorf("serve HTTP: %w", err) 65 | } 66 | slog.DebugContext(ctx, "stopped serving HTTP") 67 | return nil 68 | }) 69 | 70 | if err := m.Serve(); err != nil && !isClosedErr(err) { 71 | slog.ErrorContext(ctx, "oops", slog.Any("error", err)) 72 | return fmt.Errorf("serve cmux: %w", err) 73 | } 74 | return g.Wait() 75 | } 76 | 77 | func isClosedErr(err error) bool { 78 | return isClosedConnErr(err) || 79 | errors.Is(err, http.ErrServerClosed) || 80 | errors.Is(err, cmux.ErrServerClosed) || 81 | errors.Is(err, grpc.ErrServerStopped) 82 | } 83 | 84 | func isClosedConnErr(err error) bool { 85 | return strings.Contains(err.Error(), "use of closed network connection") 86 | } 87 | -------------------------------------------------------------------------------- /cloudmux/mux_test.go: -------------------------------------------------------------------------------- 1 | package cloudmux 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | "sync" 9 | "testing" 10 | "time" 11 | 12 | "google.golang.org/grpc" 13 | "google.golang.org/grpc/credentials/insecure" 14 | "google.golang.org/grpc/examples/helloworld/helloworld" 15 | "gotest.tools/v3/assert" 16 | ) 17 | 18 | func TestServe_Canceled(t *testing.T) { 19 | t.Parallel() 20 | fx := newTestFixture(t) 21 | 22 | var wg sync.WaitGroup 23 | wg.Add(1) 24 | go func() { 25 | fx.listen() 26 | wg.Done() 27 | }() 28 | 29 | // wait for server to be ready 30 | time.Sleep(time.Millisecond * 20) 31 | 32 | // stop listening 33 | fx.stop() 34 | 35 | wg.Wait() 36 | assert.NilError(t, fx.lisErr) 37 | } 38 | 39 | func TestServe_GracefulGRPC(t *testing.T) { 40 | t.Parallel() 41 | fx := newTestFixture(t) 42 | fx.grpc.latency = time.Second 43 | requestConn := make(chan struct{}) 44 | fx.grpc.requestRecvChan = requestConn 45 | 46 | var wg sync.WaitGroup 47 | wg.Add(1) 48 | go func() { 49 | fx.listen() 50 | wg.Done() 51 | }() 52 | 53 | client := greeterClient(t, fx.lis.Addr()) 54 | var callErr error 55 | wg.Add(1) 56 | go func() { 57 | _, callErr = client.SayHello(context.Background(), &helloworld.HelloRequest{ 58 | Name: "world", 59 | }) 60 | wg.Done() 61 | }() 62 | 63 | // wait for server to have received request 64 | <-requestConn 65 | 66 | // stop listening 67 | fx.stop() 68 | 69 | wg.Wait() 70 | assert.NilError(t, callErr) 71 | assert.NilError(t, fx.lisErr) 72 | } 73 | 74 | func TestServe_GracefulHTTP(t *testing.T) { 75 | t.Parallel() 76 | fx := newTestFixture(t) 77 | fx.http.latency = time.Second 78 | requestConn := make(chan struct{}) 79 | fx.http.requestRecvChan = requestConn 80 | 81 | var wg sync.WaitGroup 82 | wg.Add(1) 83 | go func() { 84 | fx.listen() 85 | wg.Done() 86 | }() 87 | 88 | // request needs to have a timeout in order to be blocking 89 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 90 | defer cancel() 91 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, fx.url(), nil) 92 | assert.NilError(t, err) 93 | 94 | var callErr error 95 | wg.Add(1) 96 | go func() { 97 | res, err := http.DefaultClient.Do(req) 98 | callErr = err 99 | if err == nil { 100 | _ = res.Body.Close() 101 | } 102 | wg.Done() 103 | }() 104 | 105 | // wait for server to have received request 106 | <-requestConn 107 | 108 | // stop listening 109 | fx.stop() 110 | 111 | wg.Wait() 112 | assert.NilError(t, callErr) 113 | assert.NilError(t, fx.lisErr) 114 | } 115 | 116 | func newTestFixture(t *testing.T) *testFixture { 117 | t.Helper() 118 | ctx, cancel := context.WithCancel(context.Background()) 119 | var lc net.ListenConfig 120 | lis, err := lc.Listen(ctx, "tcp", ":0") 121 | assert.NilError(t, err) 122 | 123 | grpcS := grpc.NewServer() 124 | grpcH := &grpcServer{} 125 | helloworld.RegisterGreeterServer(grpcS, grpcH) 126 | httpH := &httpServer{} 127 | //nolint:gosec 128 | httpS := &http.Server{Handler: httpH} 129 | 130 | return &testFixture{ 131 | ctx: ctx, 132 | stop: cancel, 133 | lis: lis, 134 | grpcS: grpcS, 135 | grpc: grpcH, 136 | httpS: httpS, 137 | http: httpH, 138 | } 139 | } 140 | 141 | type testFixture struct { 142 | ctx context.Context 143 | stop func() 144 | lis net.Listener 145 | grpcS *grpc.Server 146 | grpc *grpcServer 147 | httpS *http.Server 148 | http *httpServer 149 | lisErr error 150 | } 151 | 152 | func (fx *testFixture) url() string { 153 | return fmt.Sprintf("http://localhost:%d", fx.lis.Addr().(*net.TCPAddr).Port) 154 | } 155 | 156 | func (fx *testFixture) listen() { 157 | if err := ServeGRPCHTTP(fx.ctx, fx.lis, fx.grpcS, fx.httpS); err != nil { 158 | fx.lisErr = err 159 | } 160 | } 161 | 162 | func greeterClient(t *testing.T, addr net.Addr) helloworld.GreeterClient { 163 | t.Helper() 164 | conn, err := grpc.NewClient( 165 | addr.String(), 166 | grpc.WithTransportCredentials(insecure.NewCredentials()), 167 | ) 168 | assert.NilError(t, err) 169 | return helloworld.NewGreeterClient(conn) 170 | } 171 | 172 | var _ helloworld.GreeterServer = &grpcServer{} 173 | 174 | type grpcServer struct { 175 | latency time.Duration 176 | helloworld.UnimplementedGreeterServer 177 | requestRecvChan chan<- struct{} 178 | } 179 | 180 | func (g *grpcServer) SayHello( 181 | _ context.Context, 182 | request *helloworld.HelloRequest, 183 | ) (*helloworld.HelloReply, error) { 184 | if g.requestRecvChan != nil { 185 | g.requestRecvChan <- struct{}{} 186 | } 187 | if g.latency != 0 { 188 | time.Sleep(g.latency) 189 | } 190 | return &helloworld.HelloReply{Message: "Hello " + request.GetName()}, nil 191 | } 192 | 193 | type httpServer struct { 194 | requestRecvChan chan<- struct{} 195 | latency time.Duration 196 | } 197 | 198 | func (h *httpServer) ServeHTTP(w http.ResponseWriter, _ *http.Request) { 199 | if h.requestRecvChan != nil { 200 | h.requestRecvChan <- struct{}{} 201 | } 202 | if h.latency != 0 { 203 | time.Sleep(h.latency) 204 | } 205 | fmt.Fprintf(w, "Hello") 206 | } 207 | -------------------------------------------------------------------------------- /cloudotel/doc.go: -------------------------------------------------------------------------------- 1 | // Package cloudotel provides primitives for OpenTelemetry. 2 | package cloudotel 3 | -------------------------------------------------------------------------------- /cloudotel/errorhandler.go: -------------------------------------------------------------------------------- 1 | package cloudotel 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "go.opentelemetry.io/otel" 8 | "go.uber.org/zap" 9 | "go.uber.org/zap/zapcore" 10 | ) 11 | 12 | // NewErrorLogger returns a new otel.ErrorHandler that logs errors using the provided logger, level and message. 13 | // Deprecated: This is a no-op as part of the migration from zap to slog. 14 | func NewErrorLogger(*zap.Logger, zapcore.Level, string) otel.ErrorHandler { 15 | return otel.ErrorHandlerFunc(func(error) {}) 16 | } 17 | 18 | // RegisterErrorHandler registers a global OpenTelemetry error handler. 19 | func RegisterErrorHandler(ctx context.Context) { 20 | otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) { 21 | handleError(ctx, err) 22 | })) 23 | } 24 | 25 | func handleError(ctx context.Context, err error) { 26 | if isUnsupportedSamplerErr(err) { 27 | // The OpenCensus bridge does not support all features from OpenCensus, 28 | // for example custom samplers which is used in some libraries. 29 | // The bridge presumably falls back to the configured sampler, so 30 | // this error can be ignored. 31 | // 32 | // See 33 | // https://pkg.go.dev/go.opentelemetry.io/otel/bridge/opencensus 34 | return 35 | } 36 | slog.WarnContext(ctx, "otel error", slog.Any("error", err)) 37 | } 38 | -------------------------------------------------------------------------------- /cloudotel/metricexporter.go: -------------------------------------------------------------------------------- 1 | package cloudotel 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | metricexporter "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric" 11 | "go.einride.tech/cloudrunner/cloudruntime" 12 | hostinstrumentation "go.opentelemetry.io/contrib/instrumentation/host" 13 | runtimeinstrumentation "go.opentelemetry.io/contrib/instrumentation/runtime" 14 | "go.opentelemetry.io/otel" 15 | "go.opentelemetry.io/otel/attribute" 16 | ocbridge "go.opentelemetry.io/otel/bridge/opencensus" 17 | "go.opentelemetry.io/otel/sdk/instrumentation" 18 | sdkmetric "go.opentelemetry.io/otel/sdk/metric" 19 | "go.opentelemetry.io/otel/sdk/resource" 20 | semconv "go.opentelemetry.io/otel/semconv/v1.21.0" 21 | ) 22 | 23 | // MetricExporterConfig configures the metrics exporter. 24 | type MetricExporterConfig struct { 25 | Enabled bool `onGCE:"false"` 26 | Interval time.Duration `default:"60s"` 27 | RuntimeInstrumentation bool `onGCE:"true"` 28 | HostInstrumentation bool `onGCE:"true"` 29 | OpenCensusProducer bool `default:"false"` 30 | } 31 | 32 | // StartMetricExporter starts the OpenTelemetry Cloud Monitoring exporter. 33 | func StartMetricExporter( 34 | ctx context.Context, 35 | exporterConfig MetricExporterConfig, 36 | resource *resource.Resource, 37 | ) (func(context.Context) error, error) { 38 | if !exporterConfig.Enabled { 39 | return func(context.Context) error { return nil }, nil 40 | } 41 | projectID, ok := cloudruntime.ResolveProjectID(ctx) 42 | if !ok { 43 | return nil, fmt.Errorf("start metric exporter: unknown project ID") 44 | } 45 | exporter, err := metricexporter.New( 46 | metricexporter.WithProjectID(projectID), 47 | ) 48 | if err != nil { 49 | return nil, fmt.Errorf("new metric exporter: %w", err) 50 | } 51 | readerOpts := []sdkmetric.PeriodicReaderOption{ 52 | sdkmetric.WithInterval(exporterConfig.Interval), 53 | } 54 | if exporterConfig.OpenCensusProducer { 55 | readerOpts = append(readerOpts, sdkmetric.WithProducer(ocbridge.NewMetricProducer())) 56 | } 57 | reader := sdkmetric.NewPeriodicReader(exporter, readerOpts...) 58 | provider := sdkmetric.NewMeterProvider( 59 | sdkmetric.WithReader(reader), 60 | sdkmetric.WithResource(resource), 61 | sdkmetric.WithView( 62 | // `net.sock.peer.port`, `net.port.peer` and `http.client_ip are high-cardinality attributes (essentially 63 | // one unique value per request) which causes failures when exporting metrics as the request limit 64 | // towards GCP is reached (200 time series per request). 65 | // 66 | // The following views masks these attributes from both otelhttp and otelgrpc so 67 | // that metrics can still be exported. 68 | // Based on https://github.com/open-telemetry/opentelemetry-go-contrib/issues/3071#issuecomment-1416137206 69 | maskInstrumentAttrs( 70 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp", 71 | semconv.NetPeerPortKey, 72 | semconv.NetSockPeerPortKey, 73 | attribute.Key("http.client_ip"), 74 | ), 75 | maskInstrumentAttrs( 76 | "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc", 77 | semconv.NetPeerPortKey, 78 | semconv.NetSockPeerPortKey, 79 | ), 80 | ), 81 | ) 82 | otel.SetMeterProvider(provider) 83 | shutdown := func(ctx context.Context) error { 84 | if err := provider.Shutdown(ctx); err != nil { 85 | return fmt.Errorf("error stopping metric provider, final metric export might have failed: %v", err) 86 | } 87 | return nil 88 | } 89 | if exporterConfig.RuntimeInstrumentation { 90 | if err := runtimeinstrumentation.Start(); err != nil { 91 | return nil, errors.Join( 92 | shutdown(ctx), 93 | fmt.Errorf("start metric exporter: start runtime instrumentation: %w", err), 94 | ) 95 | } 96 | } 97 | if exporterConfig.HostInstrumentation { 98 | if err := hostinstrumentation.Start(); err != nil { 99 | return nil, errors.Join( 100 | shutdown(ctx), 101 | fmt.Errorf("start metric exporter: start host instrumentation: %w", err), 102 | ) 103 | } 104 | } 105 | return shutdown, nil 106 | } 107 | 108 | func isUnsupportedSamplerErr(err error) bool { 109 | if err == nil { 110 | return false 111 | } 112 | return strings.Contains(err.Error(), "unsupported sampler") 113 | } 114 | 115 | func maskInstrumentAttrs(instrumentScopeName string, attrs ...attribute.Key) sdkmetric.View { 116 | masked := make(map[attribute.Key]struct{}) 117 | for _, attr := range attrs { 118 | masked[attr] = struct{}{} 119 | } 120 | return sdkmetric.NewView( 121 | sdkmetric.Instrument{Scope: instrumentation.Scope{Name: instrumentScopeName}}, 122 | sdkmetric.Stream{ 123 | AttributeFilter: func(value attribute.KeyValue) bool { 124 | _, ok := masked[value.Key] 125 | return !ok 126 | }, 127 | }, 128 | ) 129 | } 130 | -------------------------------------------------------------------------------- /cloudotel/resource.go: -------------------------------------------------------------------------------- 1 | package cloudotel 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | 8 | "go.einride.tech/cloudrunner/cloudruntime" 9 | "go.opentelemetry.io/contrib/detectors/gcp" 10 | "go.opentelemetry.io/otel/sdk/resource" 11 | semconv "go.opentelemetry.io/otel/semconv/v1.26.0" 12 | ) 13 | 14 | // NewResource creates and detects attributes for a new OpenTelemetry resource. 15 | func NewResource(ctx context.Context) (*resource.Resource, error) { 16 | opts := []resource.Option{ 17 | resource.WithTelemetrySDK(), 18 | resource.WithDetectors(gcp.NewDetector()), 19 | } 20 | 21 | // In Cloud Run Job, Opentelemetry uses the underlying metadata instance id as the `task_id` label causing a new 22 | // time series every time the job is run which leads to a high cardinality value so we override it. 23 | // TODO: Follow-up on https://github.com/GoogleCloudPlatform/opentelemetry-operations-go/issues/874 for possible 24 | // changes on this. 25 | if e, ok := cloudruntime.TaskIndex(); ok { 26 | opts = append(opts, resource.WithAttributes(semconv.FaaSInstanceKey.String(strconv.Itoa(e)))) 27 | } 28 | if e, ok := cloudruntime.Service(); ok { 29 | opts = append(opts, resource.WithAttributes(semconv.ServiceName(e))) 30 | } 31 | result, err := resource.New(ctx, opts...) 32 | if err != nil { 33 | return nil, fmt.Errorf("init telemetry resource: %w", err) 34 | } 35 | return result, nil 36 | } 37 | -------------------------------------------------------------------------------- /cloudotel/traceexporter.go: -------------------------------------------------------------------------------- 1 | package cloudotel 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | traceexporter "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace" 9 | gcppropagator "github.com/GoogleCloudPlatform/opentelemetry-operations-go/propagator" 10 | "go.einride.tech/cloudrunner/cloudruntime" 11 | "go.opentelemetry.io/otel" 12 | "go.opentelemetry.io/otel/bridge/opencensus" 13 | "go.opentelemetry.io/otel/propagation" 14 | "go.opentelemetry.io/otel/sdk/resource" 15 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 16 | ) 17 | 18 | // TraceExporterConfig configures the trace exporter. 19 | type TraceExporterConfig struct { 20 | Enabled bool `onGCE:"true"` 21 | Timeout time.Duration `default:"10s"` 22 | SampleProbability float64 `default:"0.01"` 23 | } 24 | 25 | // StartTraceExporter starts the OpenTelemetry Cloud Trace exporter. 26 | func StartTraceExporter( 27 | ctx context.Context, 28 | exporterConfig TraceExporterConfig, 29 | resource *resource.Resource, 30 | ) (func(context.Context) error, error) { 31 | // configure open telemetry to read trace context from GCP `x-cloud-trace-context` header. 32 | otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( 33 | gcppropagator.CloudTraceFormatPropagator{}, 34 | propagation.TraceContext{}, 35 | propagation.Baggage{}, 36 | )) 37 | if !exporterConfig.Enabled { 38 | return func(context.Context) error { return nil }, nil 39 | } 40 | projectID, ok := cloudruntime.ResolveProjectID(ctx) 41 | if !ok { 42 | return nil, fmt.Errorf("start trace exporter: unknown project ID") 43 | } 44 | exporter, err := traceexporter.New( 45 | traceexporter.WithProjectID(projectID), 46 | traceexporter.WithTimeout(exporterConfig.Timeout), 47 | ) 48 | if err != nil { 49 | return nil, fmt.Errorf("start trace exporter: %w", err) 50 | } 51 | tracerProvider := sdktrace.NewTracerProvider( 52 | sdktrace.WithResource(resource), 53 | sdktrace.WithBatcher(exporter), 54 | sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(exporterConfig.SampleProbability))), 55 | ) 56 | otel.SetTracerProvider(tracerProvider) 57 | opencensus.InstallTraceBridge() 58 | cleanup := func(ctx context.Context) error { 59 | if err := tracerProvider.ForceFlush(ctx); err != nil { 60 | return fmt.Errorf("error shutting down trace exporter: %v", err) 61 | } 62 | if err := tracerProvider.Shutdown(ctx); err != nil { 63 | return fmt.Errorf("error shutting down trace exporter: %v", err) 64 | } 65 | return nil 66 | } 67 | return cleanup, nil 68 | } 69 | -------------------------------------------------------------------------------- /cloudotel/traceidhook.go: -------------------------------------------------------------------------------- 1 | package cloudotel 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "go.einride.tech/cloudrunner/cloudslog" 8 | "go.opentelemetry.io/otel/trace" 9 | ) 10 | 11 | // IDKey is the log entry key for trace IDs. 12 | // Experimental: May be removed in a future update. 13 | const IDKey = "traceId" 14 | 15 | // TraceIDHook adds the trace ID (without the full trace resource name) to the request logger. 16 | // The trace ID can be used to filter on logs for the same trace across multiple projects. 17 | // Experimental: May be removed in a future update. 18 | func TraceIDHook(ctx context.Context, traceContext trace.SpanContext) context.Context { 19 | return cloudslog.With(ctx, slog.String(IDKey, traceContext.TraceID().String())) 20 | } 21 | -------------------------------------------------------------------------------- /cloudotel/tracemiddleware.go: -------------------------------------------------------------------------------- 1 | package cloudotel 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "io" 9 | "net/http" 10 | 11 | "cloud.google.com/go/pubsub" 12 | "cloud.google.com/go/pubsub/apiv1/pubsubpb" 13 | gcppropagator "github.com/GoogleCloudPlatform/opentelemetry-operations-go/propagator" 14 | "go.einride.tech/cloudrunner/cloudpubsub" 15 | "go.einride.tech/cloudrunner/cloudstream" 16 | "go.einride.tech/cloudrunner/cloudzap" 17 | "go.opentelemetry.io/otel/propagation" 18 | "go.opentelemetry.io/otel/trace" 19 | "go.uber.org/zap" 20 | "google.golang.org/grpc" 21 | "google.golang.org/grpc/metadata" 22 | ) 23 | 24 | type TraceHook func(context.Context, trace.SpanContext) context.Context 25 | 26 | // TraceMiddleware that ensures incoming traces are forwarded and included in logging. 27 | type TraceMiddleware struct { 28 | // ProjectID of the project the service is running in. 29 | ProjectID string 30 | // TraceHook is an optional callback that gets called with the parsed trace context. 31 | TraceHook TraceHook 32 | // EnablePubsubTracing, disabled by default, reads trace parent from Pub/Sub message attributes. 33 | EnablePubsubTracing bool 34 | // propagator is a opentelemetry trace propagator 35 | propagator propagation.TextMapPropagator 36 | } 37 | 38 | func NewTraceMiddleware() TraceMiddleware { 39 | propagator := propagation.NewCompositeTextMapPropagator( 40 | gcppropagator.CloudTraceFormatPropagator{}, 41 | propagation.TraceContext{}, 42 | propagation.Baggage{}, 43 | ) 44 | return TraceMiddleware{ 45 | TraceHook: TraceIDHook, 46 | propagator: propagator, 47 | } 48 | } 49 | 50 | // GRPCServerUnaryInterceptor provides unary RPC middleware for gRPC servers. 51 | func (i *TraceMiddleware) GRPCServerUnaryInterceptor( 52 | ctx context.Context, 53 | req interface{}, 54 | _ *grpc.UnaryServerInfo, 55 | handler grpc.UnaryHandler, 56 | ) (resp interface{}, err error) { 57 | md, ok := metadata.FromIncomingContext(ctx) 58 | if !ok { 59 | return handler(ctx, req) 60 | } 61 | carrier := propagation.HeaderCarrier(md) 62 | ctx = i.propagator.Extract(ctx, carrier) 63 | ctx = i.withLogTracing(ctx, trace.SpanContextFromContext(ctx)) 64 | return handler(ctx, req) 65 | } 66 | 67 | // GRPCStreamServerInterceptor adds tracing metadata to streaming RPCs. 68 | func (i *TraceMiddleware) GRPCStreamServerInterceptor( 69 | srv interface{}, 70 | ss grpc.ServerStream, 71 | _ *grpc.StreamServerInfo, 72 | handler grpc.StreamHandler, 73 | ) (err error) { 74 | md, ok := metadata.FromIncomingContext(ss.Context()) 75 | if !ok { 76 | return handler(srv, ss) 77 | } 78 | ctx := ss.Context() 79 | carrier := propagation.HeaderCarrier(md) 80 | ctx = i.propagator.Extract(ctx, carrier) 81 | ctx = i.withLogTracing(ctx, trace.SpanContextFromContext(ctx)) 82 | return handler(srv, cloudstream.NewContextualServerStream(ctx, ss)) 83 | } 84 | 85 | // HTTPServer provides middleware for HTTP servers. 86 | func (i *TraceMiddleware) HTTPServer(next http.Handler) http.Handler { 87 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 88 | carrier := propagation.HeaderCarrier(r.Header) 89 | ctx := i.propagator.Extract(r.Context(), carrier) 90 | if i.EnablePubsubTracing { 91 | // Check if it is a Pub/Sub message and propagate tracing if exists. 92 | ctx = propagatePubsubTracing(ctx, r) 93 | } 94 | ctx = i.withLogTracing(ctx, trace.SpanContextFromContext(ctx)) 95 | next.ServeHTTP(w, r.WithContext(ctx)) 96 | }) 97 | } 98 | 99 | func (i *TraceMiddleware) withLogTracing(ctx context.Context, spanCtx trace.SpanContext) context.Context { 100 | if i.TraceHook != nil { 101 | ctx = i.TraceHook(ctx, spanCtx) 102 | } 103 | fields := make([]zap.Field, 0, 3) 104 | fields = append(fields, cloudzap.Trace(i.ProjectID, spanCtx.TraceID().String())) 105 | if spanCtx.SpanID().String() != "" { 106 | fields = append(fields, cloudzap.SpanID(spanCtx.SpanID().String())) 107 | } 108 | if spanCtx.IsSampled() { 109 | fields = append(fields, cloudzap.TraceSampled(spanCtx.IsSampled())) 110 | } 111 | return cloudzap.WithLoggerFields(ctx, fields...) 112 | } 113 | 114 | func propagatePubsubTracing(ctx context.Context, r *http.Request) context.Context { 115 | if r.Method != http.MethodPost { 116 | return ctx 117 | } 118 | body, err := io.ReadAll(r.Body) 119 | if err != nil { 120 | return ctx 121 | } 122 | // Replace the original request body, so it can be read again. 123 | r.Body = io.NopCloser(bytes.NewReader(body)) 124 | pubsubPayload, err := tryUnmarshalAsPubsubPayload(body) 125 | if err != nil { 126 | return ctx 127 | } 128 | pubsubMessage := pubsubPayload.BuildPubSubMessage() 129 | ctx = injectTracingFromPubsubMsg(ctx, &pubsubMessage) 130 | return ctx 131 | } 132 | 133 | func tryUnmarshalAsPubsubPayload(body []byte) (cloudpubsub.Payload, error) { 134 | var payload cloudpubsub.Payload 135 | if err := json.Unmarshal(body, &payload); err != nil { 136 | return payload, err 137 | } 138 | if !payload.IsValid() { 139 | return payload, errors.New("not a pubsub payload") 140 | } 141 | return payload, nil 142 | } 143 | 144 | func injectTracingFromPubsubMsg(ctx context.Context, pubsubMessage *pubsubpb.PubsubMessage) context.Context { 145 | tc := propagation.TraceContext{} 146 | ctx = tc.Extract(ctx, pubsub.NewMessageCarrierFromPB(pubsubMessage)) 147 | carrier := make(propagation.MapCarrier) 148 | tc.Inject(ctx, &carrier) 149 | return ctx 150 | } 151 | -------------------------------------------------------------------------------- /cloudotel/tracemiddleware_test.go: -------------------------------------------------------------------------------- 1 | package cloudotel 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "io" 8 | "net/http" 9 | "testing" 10 | 11 | "go.opentelemetry.io/otel/propagation" 12 | "gotest.tools/v3/assert" 13 | ) 14 | 15 | func TestPropagatePubSubTracing(t *testing.T) { 16 | t.Run("valid pubsub payload", func(t *testing.T) { 17 | // arrange 18 | ctx := context.Background() 19 | payload := `{ 20 | "subscription": "projects/test-project/subscriptions/test-sub", 21 | "message": { 22 | "attributes": { 23 | "googclient_traceparent": "00-161d8104e1e09a3e3a4d80129acbfe30-75a8fe4fee0c65b8-00" 24 | }, 25 | "data": "data", 26 | "messageId": "12345", 27 | "publishTime": "2025-03-24T12:34:56Z" 28 | }, 29 | "deliveryAttempt": "5" 30 | }` 31 | req := &http.Request{ 32 | Method: http.MethodPost, 33 | Body: newReadCloser(payload), 34 | } 35 | 36 | // act 37 | ctx = propagatePubsubTracing(ctx, req) 38 | 39 | // assert 40 | actualTraceContext := extractTraceContext(ctx) 41 | expectedTraceContext := `{"traceparent":"00-161d8104e1e09a3e3a4d80129acbfe30-75a8fe4fee0c65b8-00"}` 42 | assert.Equal(t, expectedTraceContext, actualTraceContext) 43 | 44 | actualPayload, err := io.ReadAll(req.Body) 45 | assert.NilError(t, err) 46 | assert.DeepEqual(t, string(actualPayload), payload) 47 | }) 48 | 49 | t.Run("non pubsub payload", func(t *testing.T) { 50 | // arrange 51 | ctx := context.Background() 52 | payload := `{"user": "test-user", "action": "login"}` 53 | req := &http.Request{ 54 | Method: http.MethodPost, 55 | Body: newReadCloser(payload), 56 | } 57 | 58 | // act 59 | ctx = propagatePubsubTracing(ctx, req) 60 | 61 | // assert 62 | actualTraceContext := extractTraceContext(ctx) 63 | assert.Equal(t, "{}", actualTraceContext) 64 | 65 | actualPayload, err := io.ReadAll(req.Body) 66 | assert.NilError(t, err) 67 | assert.DeepEqual(t, string(actualPayload), payload) 68 | }) 69 | 70 | t.Run("invalid payload", func(t *testing.T) { 71 | // arrange 72 | ctx := context.Background() 73 | payload := `"message":{"messageId":12345,data":"data","attributes":{"key""value"}}` 74 | req := &http.Request{ 75 | Method: http.MethodPost, 76 | Body: newReadCloser(payload), 77 | } 78 | 79 | // act 80 | ctx = propagatePubsubTracing(ctx, req) 81 | 82 | // assert 83 | actualTraceContext := extractTraceContext(ctx) 84 | assert.Equal(t, "{}", actualTraceContext) 85 | 86 | actualPayload, err := io.ReadAll(req.Body) 87 | assert.NilError(t, err) 88 | assert.DeepEqual(t, string(actualPayload), payload) 89 | }) 90 | } 91 | 92 | func extractTraceContext(ctx context.Context) string { 93 | propagator := propagation.TraceContext{} 94 | carrier := make(propagation.MapCarrier) 95 | propagator.Inject(ctx, carrier) 96 | data, err := json.Marshal(carrier) 97 | if err != nil { 98 | return "" 99 | } 100 | return string(data) 101 | } 102 | 103 | func newReadCloser(content string) io.ReadCloser { 104 | return io.NopCloser(bytes.NewReader([]byte(content))) 105 | } 106 | -------------------------------------------------------------------------------- /cloudprofiler/doc.go: -------------------------------------------------------------------------------- 1 | // Package cloudprofiler provides primitives for Cloud Profiler integration. 2 | package cloudprofiler 3 | -------------------------------------------------------------------------------- /cloudprofiler/profiler.go: -------------------------------------------------------------------------------- 1 | package cloudprofiler 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "cloud.google.com/go/profiler" 8 | "go.einride.tech/cloudrunner/cloudruntime" 9 | ) 10 | 11 | // shims for unit testing. 12 | // 13 | //nolint:gochecknoglobals 14 | var ( 15 | profilerStart = profiler.Start 16 | ) 17 | 18 | // Config configures the use of Google Cloud Profiler. 19 | type Config struct { 20 | // Enabled indicates if the profiler should be enabled. 21 | Enabled bool `onGCE:"true"` 22 | // MutexProfiling indicates if mutex profiling should be enabled. 23 | MutexProfiling bool 24 | // AllocForceGC indicates if GC should be forced before allocation profiling snapshots are taken. 25 | AllocForceGC bool `default:"true"` 26 | } 27 | 28 | // Start the profiler according to the provided Config. 29 | func Start(config Config) error { 30 | if !config.Enabled { 31 | return nil 32 | } 33 | 34 | var cloudConfig cloudruntime.Config 35 | if err := cloudConfig.Autodetect(); err != nil { 36 | return fmt.Errorf("start profiler: %w", err) 37 | } 38 | 39 | var svcConfig profiler.Config 40 | switch { 41 | case cloudConfig.Service != "": 42 | var err error 43 | svcConfig, err = cloudRunServiceConfig(cloudConfig) 44 | if err != nil { 45 | return fmt.Errorf("start profiler: %w", err) 46 | } 47 | case cloudConfig.Job != "": 48 | var err error 49 | svcConfig, err = cloudRunJobConfig(cloudConfig) 50 | if err != nil { 51 | return fmt.Errorf("start profiler: %w", err) 52 | } 53 | default: 54 | return errors.New("unable to autodetect runtime environment") 55 | } 56 | 57 | svcConfig.MutexProfiling = config.MutexProfiling 58 | svcConfig.AllocForceGC = config.AllocForceGC 59 | 60 | if err := profilerStart(svcConfig); err != nil { 61 | return fmt.Errorf("start profiler: %w", err) 62 | } 63 | return nil 64 | } 65 | 66 | func cloudRunServiceConfig(cloudCfg cloudruntime.Config) (profiler.Config, error) { 67 | return profiler.Config{ 68 | ProjectID: cloudCfg.ProjectID, 69 | Service: cloudCfg.Service, 70 | ServiceVersion: cloudCfg.ServiceVersion, 71 | }, nil 72 | } 73 | 74 | func cloudRunJobConfig(cloudCfg cloudruntime.Config) (profiler.Config, error) { 75 | return profiler.Config{ 76 | ProjectID: cloudCfg.ProjectID, 77 | Service: cloudCfg.Job, 78 | ServiceVersion: cloudCfg.Execution, 79 | }, nil 80 | } 81 | -------------------------------------------------------------------------------- /cloudprofiler/profiler_test.go: -------------------------------------------------------------------------------- 1 | package cloudprofiler 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "cloud.google.com/go/profiler" 8 | "github.com/google/go-cmp/cmp/cmpopts" 9 | "google.golang.org/api/option" 10 | "gotest.tools/v3/assert" 11 | ) 12 | 13 | func TestStart(t *testing.T) { 14 | input := Config{ 15 | Enabled: true, 16 | MutexProfiling: true, 17 | AllocForceGC: true, 18 | } 19 | expected := profiler.Config{ 20 | ProjectID: "project", 21 | Service: "service", 22 | ServiceVersion: "version", 23 | MutexProfiling: true, 24 | AllocForceGC: true, 25 | } 26 | setEnv(t, "GOOGLE_CLOUD_PROJECT", "project") 27 | setEnv(t, "K_SERVICE", "service") 28 | setEnv(t, "SERVICE_VERSION", "version") 29 | var actual profiler.Config 30 | withProfilerStart(t, func(config profiler.Config, _ ...option.ClientOption) error { 31 | actual = config 32 | return nil 33 | }) 34 | err := Start(input) 35 | assert.NilError(t, err) 36 | assert.DeepEqual(t, expected, actual, cmpopts.IgnoreUnexported(profiler.Config{})) 37 | } 38 | 39 | func withProfilerStart(t *testing.T, fn func(profiler.Config, ...option.ClientOption) error) { 40 | prev := profilerStart 41 | profilerStart = fn 42 | t.Cleanup(func() { 43 | profilerStart = prev 44 | }) 45 | } 46 | 47 | // setEnv will be available in the standard library from Go 1.17 as t.SetEnv. 48 | func setEnv(t *testing.T, key, value string) { 49 | prevValue, ok := os.LookupEnv(key) 50 | if err := os.Setenv(key, value); err != nil { 51 | t.Fatalf("cannot set environment variable: %v", err) 52 | } 53 | if ok { 54 | t.Cleanup(func() { 55 | _ = os.Setenv(key, prevValue) 56 | }) 57 | } else { 58 | t.Cleanup(func() { 59 | _ = os.Unsetenv(key) 60 | }) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /cloudpubsub/context.go: -------------------------------------------------------------------------------- 1 | package cloudpubsub 2 | 3 | import "context" 4 | 5 | type subscriptionContextKey struct{} 6 | 7 | func withSubscription(ctx context.Context, subscription string) context.Context { 8 | return context.WithValue(ctx, subscriptionContextKey{}, subscription) 9 | } 10 | 11 | // GetSubscription gets the pubsub subscription from the current context. 12 | func GetSubscription(ctx context.Context) (string, bool) { 13 | result, ok := ctx.Value(subscriptionContextKey{}).(string) 14 | return result, ok 15 | } 16 | -------------------------------------------------------------------------------- /cloudpubsub/httphandler.go: -------------------------------------------------------------------------------- 1 | package cloudpubsub 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "log/slog" 7 | "net/http" 8 | "strings" 9 | 10 | "cloud.google.com/go/pubsub/apiv1/pubsubpb" 11 | "go.einride.tech/cloudrunner/cloudrequestlog" 12 | "go.einride.tech/cloudrunner/cloudstatus" 13 | "google.golang.org/api/idtoken" 14 | "google.golang.org/grpc/status" 15 | ) 16 | 17 | // HTTPHandler creates a new HTTP handler for Cloud Pub/Sub push messages. 18 | // See: https://cloud.google.com/pubsub/docs/push 19 | func HTTPHandler(fn func(context.Context, *pubsubpb.PubsubMessage) error) http.Handler { 20 | return httpHandlerFn(fn) 21 | } 22 | 23 | type httpHandlerFn func(ctx context.Context, message *pubsubpb.PubsubMessage) error 24 | 25 | func (fn httpHandlerFn) ServeHTTP(w http.ResponseWriter, r *http.Request) { 26 | if r.Method != http.MethodPost { 27 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) 28 | return 29 | } 30 | var payload Payload 31 | if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { 32 | if fields, ok := cloudrequestlog.GetAdditionalFields(r.Context()); ok { 33 | fields.Add(slog.Any("error", err)) 34 | } 35 | http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 36 | return 37 | } 38 | pubsubMessage := payload.BuildPubSubMessage() 39 | if fields, ok := cloudrequestlog.GetAdditionalFields(r.Context()); ok { 40 | fields.Add(slog.Any("pubsubMessage", &pubsubMessage)) 41 | } 42 | ctx := withSubscription(r.Context(), payload.Subscription) 43 | if err := fn(ctx, &pubsubMessage); err != nil { 44 | if fields, ok := cloudrequestlog.GetAdditionalFields(r.Context()); ok { 45 | fields.Add(slog.Any("error", err)) 46 | } 47 | code := status.Code(err) 48 | httpStatus := cloudstatus.ToHTTP(code) 49 | http.Error(w, http.StatusText(httpStatus), httpStatus) 50 | return 51 | } 52 | } 53 | 54 | // AuthenticatedHTTPHandler creates a new HTTP handler for authenticated Cloud Pub/Sub push messages, and verifies the 55 | // token passed in the Authorization header. 56 | // See: https://cloud.google.com/pubsub/docs/authenticate-push-subscriptions 57 | // 58 | // The audience parameter is optional and only verified against the token audience claim if non-empty. 59 | // If audience isn't configured in the push subscription configuration, it defaults to the push endpoint URL. 60 | // See: https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions#oidctoken 61 | // 62 | // The allowedEmails list is optional, and if non-empty will verify that it contains the token email claim. 63 | func AuthenticatedHTTPHandler( 64 | fn func(context.Context, *pubsubpb.PubsubMessage) error, 65 | audience string, 66 | allowedEmails ...string, 67 | ) http.Handler { 68 | emails := make(map[string]struct{}, len(allowedEmails)) 69 | for _, email := range allowedEmails { 70 | emails[email] = struct{}{} 71 | } 72 | 73 | handler := httpHandlerFn(fn) 74 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 75 | // Inspired by https://cloud.google.com/pubsub/docs/authenticate-push-subscriptions#go, 76 | // but always return HTTP 401 for all authentication errors. 77 | authHeader := r.Header.Get("Authorization") 78 | if authHeader == "" { 79 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 80 | return 81 | } 82 | 83 | headerParts := strings.Split(authHeader, " ") 84 | if len(headerParts) != 2 || headerParts[0] != "Bearer" { 85 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 86 | return 87 | } 88 | 89 | token := headerParts[1] 90 | payload, err := idtoken.Validate(r.Context(), token, audience) 91 | if err != nil { 92 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 93 | return 94 | } 95 | 96 | if payload.Issuer != "accounts.google.com" && payload.Issuer != "https://accounts.google.com" { 97 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 98 | return 99 | } 100 | 101 | if len(allowedEmails) > 0 { 102 | email, ok := payload.Claims["email"].(string) 103 | if !ok || payload.Claims["email_verified"] != true { 104 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 105 | return 106 | } 107 | if _, found := emails[email]; !found { 108 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 109 | return 110 | } 111 | } 112 | 113 | // Authenticated, pass along to handler. 114 | handler.ServeHTTP(w, r) 115 | }) 116 | } 117 | -------------------------------------------------------------------------------- /cloudpubsub/httphandler_test.go: -------------------------------------------------------------------------------- 1 | package cloudpubsub 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | 10 | "cloud.google.com/go/pubsub/apiv1/pubsubpb" 11 | "google.golang.org/protobuf/testing/protocmp" 12 | "google.golang.org/protobuf/types/known/timestamppb" 13 | "gotest.tools/v3/assert" 14 | ) 15 | 16 | func TestNewHTTPHandler(t *testing.T) { 17 | ctx := context.Background() 18 | // From: https://cloud.google.com/pubsub/docs/push#receiving_messages 19 | const example = ` 20 | { 21 | "message": { 22 | "attributes": { 23 | "key": "value" 24 | }, 25 | "data": "SGVsbG8gQ2xvdWQgUHViL1N1YiEgSGVyZSBpcyBteSBtZXNzYWdlIQ==", 26 | "messageId": "2070443601311540", 27 | "message_id": "2070443601311540", 28 | "publishTime": "2021-02-26T19:13:55.749Z", 29 | "publish_time": "2021-02-26T19:13:55.749Z" 30 | }, 31 | "subscription": "projects/myproject/subscriptions/mysubscription" 32 | } 33 | ` 34 | expectedMessage := &pubsubpb.PubsubMessage{ 35 | Data: []byte("Hello Cloud Pub/Sub! Here is my message!"), 36 | Attributes: map[string]string{"key": "value"}, 37 | MessageId: "2070443601311540", 38 | PublishTime: ×tamppb.Timestamp{ 39 | Seconds: 1614366835, 40 | Nanos: 749000000, 41 | }, 42 | } 43 | var actualMessage *pubsubpb.PubsubMessage 44 | var subscription string 45 | var subscriptionOk bool 46 | fn := func(ctx context.Context, message *pubsubpb.PubsubMessage) error { 47 | actualMessage = message 48 | subscription, subscriptionOk = GetSubscription(ctx) 49 | return nil 50 | } 51 | server := httptest.NewServer(HTTPHandler(fn)) 52 | defer server.Close() 53 | request, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, strings.NewReader(example)) 54 | assert.NilError(t, err) 55 | response, err := http.DefaultClient.Do(request) 56 | assert.NilError(t, err) 57 | t.Cleanup(func() { 58 | assert.NilError(t, response.Body.Close()) 59 | }) 60 | assert.Equal(t, http.StatusOK, response.StatusCode) 61 | assert.DeepEqual(t, expectedMessage, actualMessage, protocmp.Transform()) 62 | assert.Assert(t, subscriptionOk) 63 | assert.Equal(t, subscription, "projects/myproject/subscriptions/mysubscription") 64 | } 65 | -------------------------------------------------------------------------------- /cloudpubsub/payload.go: -------------------------------------------------------------------------------- 1 | package cloudpubsub 2 | 3 | import ( 4 | "time" 5 | 6 | "cloud.google.com/go/pubsub/apiv1/pubsubpb" 7 | "google.golang.org/protobuf/types/known/timestamppb" 8 | ) 9 | 10 | type PubSubMessage struct { 11 | Attributes map[string]string `json:"attributes"` 12 | Data []byte `json:"data"` 13 | MessageID string `json:"messageId"` 14 | PublishTime time.Time `json:"publishTime"` 15 | OrderingKey string `json:"orderingKey"` 16 | } 17 | 18 | type Payload struct { 19 | Subscription string `json:"subscription"` 20 | Message PubSubMessage `json:"message"` 21 | } 22 | 23 | func (p Payload) IsValid() bool { 24 | return p.Message.MessageID != "" 25 | } 26 | 27 | func (p Payload) BuildPubSubMessage() pubsubpb.PubsubMessage { 28 | return pubsubpb.PubsubMessage{ 29 | Data: p.Message.Data, 30 | Attributes: p.Message.Attributes, 31 | MessageId: p.Message.MessageID, 32 | PublishTime: timestamppb.New(p.Message.PublishTime), 33 | OrderingKey: p.Message.OrderingKey, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /cloudrequestlog/additionalfields.go: -------------------------------------------------------------------------------- 1 | package cloudrequestlog 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "sync" 7 | 8 | "go.uber.org/zap/zapcore" 9 | ) 10 | 11 | type additionalFieldsKey struct{} 12 | 13 | // GetAdditionalFields returns the current request metadata. 14 | func GetAdditionalFields(ctx context.Context) (*AdditionalFields, bool) { 15 | md, ok := ctx.Value(additionalFieldsKey{}).(*AdditionalFields) 16 | return md, ok 17 | } 18 | 19 | // WithAdditionalFields initializes metadata for the current request. 20 | func WithAdditionalFields(ctx context.Context) context.Context { 21 | return context.WithValue(ctx, additionalFieldsKey{}, &AdditionalFields{}) 22 | } 23 | 24 | // AdditionalFields for a request log message. 25 | type AdditionalFields struct { 26 | mu sync.Mutex 27 | attrs []slog.Attr 28 | arrays []*arrayField 29 | } 30 | 31 | type arrayField struct { 32 | key string 33 | values []any 34 | } 35 | 36 | // Add additional attrs. 37 | func (m *AdditionalFields) Add(args ...any) { 38 | m.mu.Lock() 39 | m.attrs = append(m.attrs, argsToAttrSlice(args)...) 40 | m.mu.Unlock() 41 | } 42 | 43 | // AddToArray adds additional objects to an array field. 44 | func (m *AdditionalFields) AddToArray(key string, objects ...any) { 45 | m.mu.Lock() 46 | defer m.mu.Unlock() 47 | var array *arrayField 48 | for _, needle := range m.arrays { 49 | if needle.key == key { 50 | array = needle 51 | break 52 | } 53 | } 54 | if array == nil { 55 | array = &arrayField{key: key} 56 | m.arrays = append(m.arrays, array) 57 | } 58 | array.values = append(array.values, objects...) 59 | } 60 | 61 | // AppendTo appends the additional attrs to the input attrs. 62 | func (m *AdditionalFields) AppendTo(attrs []slog.Attr) []slog.Attr { 63 | m.mu.Lock() 64 | defer m.mu.Unlock() 65 | attrs = append(attrs, m.attrs...) 66 | for _, array := range m.arrays { 67 | attrs = append(attrs, slog.Any(array.key, array.values)) 68 | } 69 | return attrs 70 | } 71 | 72 | func argsToAttrSlice(args []any) []slog.Attr { 73 | var attr slog.Attr 74 | fields := make([]slog.Attr, 0, len(args)) 75 | for len(args) > 0 { 76 | attr, args = argsToAttr(args) 77 | fields = append(fields, attr) 78 | } 79 | return fields 80 | } 81 | 82 | // argsToAttr is copied from the slog stdlib. 83 | func argsToAttr(args []any) (slog.Attr, []any) { 84 | const badKey = "!BADKEY" 85 | switch x := args[0].(type) { 86 | case string: 87 | if len(args) == 1 { 88 | return slog.String(badKey, x), nil 89 | } 90 | return slog.Any(x, args[1]), args[2:] 91 | case zapcore.Field: 92 | return fieldToAttr(x), args[1:] 93 | case slog.Attr: 94 | return x, args[1:] 95 | default: 96 | return slog.Any(badKey, x), args[1:] 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /cloudrequestlog/codetolevel.go: -------------------------------------------------------------------------------- 1 | package cloudrequestlog 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "google.golang.org/grpc/codes" 7 | ) 8 | 9 | // CodeToLevel returns the default [slog.Level] for requests with the provided [codes.Code]. 10 | func CodeToLevel(code codes.Code) slog.Level { 11 | switch code { 12 | case codes.OK: 13 | return slog.LevelInfo 14 | case 15 | codes.NotFound, 16 | codes.InvalidArgument, 17 | codes.AlreadyExists, 18 | codes.FailedPrecondition, 19 | codes.Unauthenticated, 20 | codes.PermissionDenied, 21 | codes.DeadlineExceeded, 22 | codes.OutOfRange, 23 | codes.Canceled, 24 | codes.Aborted, 25 | codes.Unavailable, 26 | codes.ResourceExhausted, 27 | codes.Unimplemented: 28 | return slog.LevelWarn 29 | default: 30 | return slog.LevelError 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /cloudrequestlog/config.go: -------------------------------------------------------------------------------- 1 | package cloudrequestlog 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "google.golang.org/grpc/codes" 7 | ) 8 | 9 | // Config contains request logging config. 10 | type Config struct { 11 | // MessageSizeLimit is the maximum size, in bytes, of requests and responses to log. 12 | // Messages larger than the limit will be truncated. 13 | // Default value, 0, means that no messages will be truncated. 14 | MessageSizeLimit int `onGCE:"1024"` 15 | // CodeToLevel enables overriding the default gRPC code to level conversion. 16 | CodeToLevel map[codes.Code]slog.Level 17 | // StatusToLevel enables overriding the default HTTP status code to level conversion. 18 | StatusToLevel map[int]slog.Level 19 | } 20 | -------------------------------------------------------------------------------- /cloudrequestlog/details.go: -------------------------------------------------------------------------------- 1 | package cloudrequestlog 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "go.uber.org/zap" 7 | "go.uber.org/zap/zapcore" 8 | "google.golang.org/grpc/status" 9 | "google.golang.org/protobuf/encoding/protojson" 10 | "google.golang.org/protobuf/proto" 11 | "google.golang.org/protobuf/types/known/anypb" 12 | ) 13 | 14 | // ErrorDetails creates a zap.Field that logs the gRPC error details of the provided error. 15 | func ErrorDetails(err error) zap.Field { 16 | if err == nil { 17 | return zap.Skip() 18 | } 19 | s, ok := status.FromError(err) 20 | if !ok { 21 | return zap.Skip() 22 | } 23 | protoDetails := s.Proto().GetDetails() 24 | if len(protoDetails) == 0 { 25 | return zap.Skip() 26 | } 27 | return zap.Array("errorDetails", errorDetailsMarshaler(protoDetails)) 28 | } 29 | 30 | type errorDetailsMarshaler []*anypb.Any 31 | 32 | var _ zapcore.ArrayMarshaler = errorDetailsMarshaler{} 33 | 34 | // MarshalLogArray implements zapcore.ArrayMarshaler. 35 | func (d errorDetailsMarshaler) MarshalLogArray(encoder zapcore.ArrayEncoder) error { 36 | for _, detail := range d { 37 | if err := encoder.AppendReflected(reflectProtoMessage{message: detail}); err != nil { 38 | return err 39 | } 40 | } 41 | return nil 42 | } 43 | 44 | type reflectProtoMessage struct { 45 | message proto.Message 46 | } 47 | 48 | var _ json.Marshaler = reflectProtoMessage{} 49 | 50 | func (p reflectProtoMessage) MarshalJSON() ([]byte, error) { 51 | return protojson.Marshal(p.message) 52 | } 53 | -------------------------------------------------------------------------------- /cloudrequestlog/doc.go: -------------------------------------------------------------------------------- 1 | // Package cloudrequestlog contains primitives for request logging. 2 | package cloudrequestlog 3 | -------------------------------------------------------------------------------- /cloudrequestlog/fullmethod.go: -------------------------------------------------------------------------------- 1 | package cloudrequestlog 2 | 3 | import "strings" 4 | 5 | func splitFullMethod(fullMethod string) (service, method string, ok bool) { 6 | serviceAndMethod := strings.SplitN(strings.TrimPrefix(fullMethod, "/"), "/", 2) 7 | if len(serviceAndMethod) != 2 { 8 | return "", "", false 9 | } 10 | return serviceAndMethod[0], serviceAndMethod[1], true 11 | } 12 | -------------------------------------------------------------------------------- /cloudrequestlog/httpresponsewriter.go: -------------------------------------------------------------------------------- 1 | package cloudrequestlog 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | ) 9 | 10 | type httpResponseWriter struct { 11 | http.ResponseWriter 12 | statusCode int 13 | size int 14 | } 15 | 16 | func (w *httpResponseWriter) WriteHeader(statusCode int) { 17 | w.statusCode = statusCode 18 | w.ResponseWriter.WriteHeader(statusCode) 19 | } 20 | 21 | func (w *httpResponseWriter) Status() int { 22 | if w.statusCode == 0 { 23 | return http.StatusOK 24 | } 25 | return w.statusCode 26 | } 27 | 28 | func (w *httpResponseWriter) Write(b []byte) (int, error) { 29 | size, err := w.ResponseWriter.Write(b) 30 | w.size += size 31 | return size, err 32 | } 33 | 34 | // Flush sends any buffered data to the client. 35 | func (w *httpResponseWriter) Flush() { 36 | if flusher, ok := w.ResponseWriter.(http.Flusher); ok { 37 | flusher.Flush() 38 | } 39 | } 40 | 41 | func (w *httpResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 42 | if hijacker, ok := w.ResponseWriter.(http.Hijacker); ok { 43 | return hijacker.Hijack() 44 | } 45 | return nil, nil, fmt.Errorf("underlying ResponseWriter does not implement http.Hijacker") 46 | } 47 | -------------------------------------------------------------------------------- /cloudrequestlog/logmessage.go: -------------------------------------------------------------------------------- 1 | package cloudrequestlog 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | "google.golang.org/grpc/codes" 9 | ) 10 | 11 | func grpcServerLogMessage(code codes.Code, fullMethod string) string { 12 | methodName := fullMethod 13 | if _, method, ok := splitFullMethod(fullMethod); ok { 14 | methodName = method 15 | } 16 | return fmt.Sprintf("gRPCServer %s %s", code.String(), methodName) 17 | } 18 | 19 | func grpcClientLogMessage(code codes.Code, fullMethod string) string { 20 | methodName := fullMethod 21 | if _, method, ok := splitFullMethod(fullMethod); ok { 22 | methodName = method 23 | } 24 | return fmt.Sprintf("gRPCClient %s %s", code.String(), methodName) 25 | } 26 | 27 | func httpServerLogMessage(res *httpResponseWriter, req *http.Request) string { 28 | return fmt.Sprintf( 29 | "HTTPServer %d %s/%s", 30 | res.Status(), 31 | req.Host, 32 | strings.TrimPrefix(req.RequestURI, "/"), 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /cloudrequestlog/migration.go: -------------------------------------------------------------------------------- 1 | package cloudrequestlog 2 | 3 | import ( 4 | "log/slog" 5 | "math" 6 | "time" 7 | 8 | "go.uber.org/zap/zapcore" 9 | ) 10 | 11 | func fieldToAttr(field zapcore.Field) slog.Attr { 12 | switch field.Type { 13 | case zapcore.StringType: 14 | return slog.String(field.Key, field.String) 15 | case zapcore.Int64Type: 16 | return slog.Int64(field.Key, field.Integer) 17 | case zapcore.Int32Type: 18 | return slog.Int(field.Key, int(field.Integer)) 19 | case zapcore.Uint64Type: 20 | return slog.Uint64(field.Key, uint64(field.Integer)) 21 | case zapcore.Float64Type: 22 | return slog.Float64(field.Key, math.Float64frombits(uint64(field.Integer))) 23 | case zapcore.BoolType: 24 | return slog.Bool(field.Key, field.Integer == 1) 25 | case zapcore.TimeType: 26 | if field.Interface != nil { 27 | loc, ok := field.Interface.(*time.Location) 28 | if ok { 29 | return slog.Time(field.Key, time.Unix(0, field.Integer).In(loc)) 30 | } 31 | } 32 | return slog.Time(field.Key, time.Unix(0, field.Integer)) 33 | case zapcore.DurationType: 34 | return slog.Duration(field.Key, time.Duration(field.Integer)) 35 | default: 36 | return slog.Any(field.Key, field.Interface) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /cloudruntime/config.go: -------------------------------------------------------------------------------- 1 | package cloudruntime 2 | 3 | import "context" 4 | 5 | // WithConfig adds the provided runtime Config to the current context. 6 | func WithConfig(ctx context.Context, config Config) context.Context { 7 | return context.WithValue(ctx, configContextKey{}, config) 8 | } 9 | 10 | // GetConfig gets the runtime Config from the current context. 11 | func GetConfig(ctx context.Context) (Config, bool) { 12 | result, ok := ctx.Value(configContextKey{}).(Config) 13 | return result, ok 14 | } 15 | 16 | type configContextKey struct{} 17 | 18 | // Config is the runtime config for the service. 19 | type Config struct { 20 | // Port is the port the service is listening on. 21 | Port int `env:"PORT" default:"8080"` 22 | // Service is the name of the service. 23 | Service string `env:"K_SERVICE"` 24 | // Revision of the service, as assigned by a Knative runtime. 25 | Revision string `env:"K_REVISION"` 26 | // Configuration of the service, as assigned by a Knative runtime. 27 | Configuration string `env:"K_CONFIGURATION"` 28 | // Job name, if running as a Cloud Run job. 29 | Job string `env:"CLOUD_RUN_JOB"` 30 | // Execution name, if running as a Cloud Run job. 31 | Execution string `env:"CLOUD_RUN_EXECUTION"` 32 | // TaskIndex of the current task, if running as a Cloud Run job. 33 | TaskIndex int `env:"CLOUD_RUN_TASK_INDEX"` 34 | // TaskAttempt of the current task, if running as a Cloud Run job. 35 | TaskAttempt int `env:"CLOUD_RUN_TASK_ATTEMPT"` 36 | // TaskCount of the job, if running as a Cloud Run job. 37 | TaskCount int `env:"CLOUD_RUN_TASK_COUNT"` 38 | // ProjectID is the GCP project ID the service is running in. 39 | // In production, defaults to the project where the service is deployed. 40 | ProjectID string `env:"GOOGLE_CLOUD_PROJECT"` 41 | // ServiceAccount is the service account used by the service. 42 | // In production, defaults to the default service account of the running service. 43 | ServiceAccount string 44 | // ServiceVersion is the version of the service. 45 | ServiceVersion string `env:"SERVICE_VERSION"` 46 | // EnablePubsubTracing, disabled by default, reads trace parent from Pub/Sub message attributes. 47 | EnablePubsubTracing bool `env:"ENABLE_PUBSUB_TRACING"` 48 | } 49 | 50 | // Resolve the runtime config. 51 | func (c *Config) Resolve(ctx context.Context) error { 52 | if projectID, ok := ResolveProjectID(ctx); ok { 53 | c.ProjectID = projectID 54 | } 55 | if serviceVersion, ok := ServiceVersion(); ok { 56 | c.ServiceVersion = serviceVersion 57 | } 58 | if serviceAccount, ok := ResolveServiceAccount(ctx); ok { 59 | c.ServiceAccount = serviceAccount 60 | } 61 | if service, ok := Service(); ok { 62 | c.Service = service 63 | } 64 | if revision, ok := Revision(); ok { 65 | c.Revision = revision 66 | } 67 | if configuration, ok := Configuration(); ok { 68 | c.Configuration = configuration 69 | } 70 | if job, ok := Job(); ok { 71 | c.Job = job 72 | } 73 | if execution, ok := Execution(); ok { 74 | c.Execution = execution 75 | } 76 | if taskIndex, ok := TaskIndex(); ok { 77 | c.TaskIndex = taskIndex 78 | } 79 | if taskAttempt, ok := TaskAttempt(); ok { 80 | c.TaskAttempt = taskAttempt 81 | } 82 | if taskCount, ok := TaskCount(); ok { 83 | c.TaskCount = taskCount 84 | } 85 | if enablePubsubTracing, ok := EnablePubsubTracing(); ok { 86 | c.EnablePubsubTracing = enablePubsubTracing 87 | } 88 | return nil 89 | } 90 | 91 | // Autodetect the runtime config. 92 | // Deprecated: Use the context-based [Config.Resolve] method instead. 93 | func (c *Config) Autodetect() error { 94 | return c.Resolve(context.Background()) 95 | } 96 | -------------------------------------------------------------------------------- /cloudruntime/doc.go: -------------------------------------------------------------------------------- 1 | // Package cloudruntime provides primitives for loading data from the cloud runtime. 2 | package cloudruntime 3 | -------------------------------------------------------------------------------- /cloudruntime/env.go: -------------------------------------------------------------------------------- 1 | package cloudruntime 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "strconv" 7 | ) 8 | 9 | // Service returns the service name of the current runtime. 10 | func Service() (string, bool) { 11 | if service, ok := os.LookupEnv("K_SERVICE"); ok { 12 | return service, true 13 | } 14 | // Default to the name of the entrypoint command. 15 | return path.Base(os.Args[0]), true 16 | } 17 | 18 | // Revision returns the service revision of the current runtime. 19 | func Revision() (string, bool) { 20 | return os.LookupEnv("K_REVISION") 21 | } 22 | 23 | // Configuration returns the service configuration of the current runtime. 24 | func Configuration() (string, bool) { 25 | return os.LookupEnv("K_CONFIGURATION") 26 | } 27 | 28 | // Port returns the service port of the current runtime. 29 | func Port() (int, bool) { 30 | port, err := strconv.Atoi(os.Getenv("PORT")) 31 | return port, err == nil 32 | } 33 | 34 | // Job returns the name of the Cloud Run job being run. 35 | func Job() (string, bool) { 36 | return os.LookupEnv("CLOUD_RUN_JOB") 37 | } 38 | 39 | // Execution returns the name of the Cloud Run job execution being run. 40 | func Execution() (string, bool) { 41 | return os.LookupEnv("CLOUD_RUN_EXECUTION") 42 | } 43 | 44 | // TaskIndex returns the index of the Cloud Run job task being run. 45 | // Starts at 0 for the first task and increments by 1 for every successive task, 46 | // up to the maximum number of tasks minus 1. 47 | func TaskIndex() (int, bool) { 48 | taskIndex, err := strconv.Atoi(os.Getenv("CLOUD_RUN_TASK_INDEX")) 49 | return taskIndex, err == nil 50 | } 51 | 52 | // TaskAttempt returns the number of time this Cloud Run job task tas been retried. 53 | // Starts at 0 for the first attempt and increments by 1 for every successive retry, 54 | // up to the maximum retries value. 55 | func TaskAttempt() (int, bool) { 56 | taskIndex, err := strconv.Atoi(os.Getenv("CLOUD_RUN_TASK_ATTEMPT")) 57 | return taskIndex, err == nil 58 | } 59 | 60 | // TaskCount returns the number of tasks in the current Cloud Run job. 61 | func TaskCount() (int, bool) { 62 | taskIndex, err := strconv.Atoi(os.Getenv("CLOUD_RUN_TASK_COUNT")) 63 | return taskIndex, err == nil 64 | } 65 | 66 | // EnablePubsubTracing returns a boolean indicating whether Pub/Sub tracing is enabled (false by default). 67 | func EnablePubsubTracing() (bool, bool) { 68 | enablePubsubTracing, err := strconv.ParseBool(os.Getenv("ENABLE_PUBSUB_TRACING")) 69 | return enablePubsubTracing, err == nil 70 | } 71 | -------------------------------------------------------------------------------- /cloudruntime/env_test.go: -------------------------------------------------------------------------------- 1 | package cloudruntime 2 | 3 | import ( 4 | "os" 5 | "strconv" 6 | "testing" 7 | 8 | "gotest.tools/v3/assert" 9 | ) 10 | 11 | func TestService(t *testing.T) { 12 | t.Run("from env", func(t *testing.T) { 13 | const expected = "foo" 14 | setEnv(t, "K_SERVICE", expected) 15 | actual, ok := Service() 16 | assert.Assert(t, ok) 17 | assert.Equal(t, expected, actual) 18 | }) 19 | 20 | t.Run("defaults to name of binary", func(t *testing.T) { 21 | actual, ok := Service() 22 | assert.Assert(t, ok) 23 | assert.Assert(t, actual != "") // don't assume the name of the test binary 24 | }) 25 | } 26 | 27 | func TestRevision(t *testing.T) { 28 | t.Run("from env", func(t *testing.T) { 29 | const expected = "foo" 30 | setEnv(t, "K_REVISION", expected) 31 | actual, ok := Revision() 32 | assert.Assert(t, ok) 33 | assert.Equal(t, expected, actual) 34 | }) 35 | 36 | t.Run("undefined", func(t *testing.T) { 37 | actual, ok := Revision() 38 | assert.Assert(t, !ok) 39 | assert.Equal(t, "", actual) 40 | }) 41 | } 42 | 43 | func TestConfiguration(t *testing.T) { 44 | t.Run("from env", func(t *testing.T) { 45 | const expected = "foo" 46 | setEnv(t, "K_CONFIGURATION", expected) 47 | actual, ok := Configuration() 48 | assert.Assert(t, ok) 49 | assert.Equal(t, expected, actual) 50 | }) 51 | 52 | t.Run("undefined", func(t *testing.T) { 53 | actual, ok := Configuration() 54 | assert.Assert(t, !ok) 55 | assert.Equal(t, "", actual) 56 | }) 57 | } 58 | 59 | func TestPort(t *testing.T) { 60 | t.Run("from env", func(t *testing.T) { 61 | const expected = 42 62 | setEnv(t, "PORT", strconv.Itoa(expected)) 63 | actual, ok := Port() 64 | assert.Assert(t, ok) 65 | assert.Equal(t, expected, actual) 66 | }) 67 | 68 | t.Run("invalid", func(t *testing.T) { 69 | setEnv(t, "PORT", "invalid") 70 | actual, ok := Port() 71 | assert.Assert(t, !ok) 72 | assert.Equal(t, 0, actual) 73 | }) 74 | 75 | t.Run("undefined", func(t *testing.T) { 76 | actual, ok := Port() 77 | assert.Assert(t, !ok) 78 | assert.Equal(t, 0, actual) 79 | }) 80 | } 81 | 82 | func TestJob(t *testing.T) { 83 | t.Run("from env", func(t *testing.T) { 84 | const expected = "foo" 85 | setEnv(t, "CLOUD_RUN_JOB", expected) 86 | actual, ok := Job() 87 | assert.Assert(t, ok) 88 | assert.Equal(t, expected, actual) 89 | }) 90 | 91 | t.Run("undefined", func(t *testing.T) { 92 | actual, ok := Job() 93 | assert.Assert(t, !ok) 94 | assert.Equal(t, "", actual) 95 | }) 96 | } 97 | 98 | func TestExecution(t *testing.T) { 99 | t.Run("from env", func(t *testing.T) { 100 | const expected = "foo" 101 | setEnv(t, "CLOUD_RUN_EXECUTION", expected) 102 | actual, ok := Execution() 103 | assert.Assert(t, ok) 104 | assert.Equal(t, expected, actual) 105 | }) 106 | 107 | t.Run("undefined", func(t *testing.T) { 108 | actual, ok := Execution() 109 | assert.Assert(t, !ok) 110 | assert.Equal(t, "", actual) 111 | }) 112 | } 113 | 114 | func TestTaskIndex(t *testing.T) { 115 | t.Run("from env", func(t *testing.T) { 116 | const expected = 42 117 | setEnv(t, "CLOUD_RUN_TASK_INDEX", strconv.Itoa(expected)) 118 | actual, ok := TaskIndex() 119 | assert.Assert(t, ok) 120 | assert.Equal(t, expected, actual) 121 | }) 122 | 123 | t.Run("invalid", func(t *testing.T) { 124 | setEnv(t, "CLOUD_RUN_TASK_INDEX", "invalid") 125 | actual, ok := TaskIndex() 126 | assert.Assert(t, !ok) 127 | assert.Equal(t, 0, actual) 128 | }) 129 | 130 | t.Run("undefined", func(t *testing.T) { 131 | actual, ok := TaskIndex() 132 | assert.Assert(t, !ok) 133 | assert.Equal(t, 0, actual) 134 | }) 135 | } 136 | 137 | func TestTaskAttempt(t *testing.T) { 138 | t.Run("from env", func(t *testing.T) { 139 | const expected = 42 140 | setEnv(t, "CLOUD_RUN_TASK_ATTEMPT", strconv.Itoa(expected)) 141 | actual, ok := TaskAttempt() 142 | assert.Assert(t, ok) 143 | assert.Equal(t, expected, actual) 144 | }) 145 | 146 | t.Run("invalid", func(t *testing.T) { 147 | setEnv(t, "CLOUD_RUN_TASK_ATTEMPT", "invalid") 148 | actual, ok := TaskAttempt() 149 | assert.Assert(t, !ok) 150 | assert.Equal(t, 0, actual) 151 | }) 152 | 153 | t.Run("undefined", func(t *testing.T) { 154 | actual, ok := TaskAttempt() 155 | assert.Assert(t, !ok) 156 | assert.Equal(t, 0, actual) 157 | }) 158 | } 159 | 160 | func TestTaskCount(t *testing.T) { 161 | t.Run("from env", func(t *testing.T) { 162 | const expected = 42 163 | setEnv(t, "CLOUD_RUN_TASK_COUNT", strconv.Itoa(expected)) 164 | actual, ok := TaskCount() 165 | assert.Assert(t, ok) 166 | assert.Equal(t, expected, actual) 167 | }) 168 | 169 | t.Run("invalid", func(t *testing.T) { 170 | setEnv(t, "CLOUD_RUN_TASK_COUNT", "invalid") 171 | actual, ok := TaskCount() 172 | assert.Assert(t, !ok) 173 | assert.Equal(t, 0, actual) 174 | }) 175 | 176 | t.Run("undefined", func(t *testing.T) { 177 | actual, ok := TaskCount() 178 | assert.Assert(t, !ok) 179 | assert.Equal(t, 0, actual) 180 | }) 181 | } 182 | 183 | func TestEnablePubsubTracing(t *testing.T) { 184 | t.Run("disabled from env", func(t *testing.T) { 185 | const expected = false 186 | setEnv(t, "ENABLE_PUBSUB_TRACING", "false") 187 | actual, ok := EnablePubsubTracing() 188 | assert.Assert(t, ok) 189 | assert.Equal(t, expected, actual) 190 | }) 191 | 192 | t.Run("enabled from env", func(t *testing.T) { 193 | const expected = true 194 | setEnv(t, "ENABLE_PUBSUB_TRACING", "true") 195 | actual, ok := EnablePubsubTracing() 196 | assert.Assert(t, ok) 197 | assert.Equal(t, expected, actual) 198 | }) 199 | 200 | t.Run("undefined", func(t *testing.T) { 201 | const expected = false 202 | actual, ok := EnablePubsubTracing() 203 | assert.Assert(t, !ok) 204 | assert.Equal(t, expected, actual) 205 | }) 206 | 207 | t.Run("invalid", func(t *testing.T) { 208 | const expected = false 209 | setEnv(t, "ENABLE_PUBSUB_TRACING", "invalid") 210 | actual, ok := EnablePubsubTracing() 211 | assert.Assert(t, !ok) 212 | assert.Equal(t, expected, actual) 213 | }) 214 | } 215 | 216 | // setEnv will be available in the standard library from Go 1.17 as t.SetEnv. 217 | func setEnv(t *testing.T, key, value string) { 218 | prevValue, ok := os.LookupEnv(key) 219 | if err := os.Setenv(key, value); err != nil { 220 | t.Fatalf("cannot set environment variable: %v", err) 221 | } 222 | if ok { 223 | t.Cleanup(func() { 224 | _ = os.Setenv(key, prevValue) 225 | }) 226 | } else { 227 | t.Cleanup(func() { 228 | _ = os.Unsetenv(key) 229 | }) 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /cloudruntime/metadata.go: -------------------------------------------------------------------------------- 1 | package cloudruntime 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/url" 8 | "os" 9 | "strings" 10 | 11 | "cloud.google.com/go/compute/metadata" 12 | "golang.org/x/oauth2/google" 13 | ) 14 | 15 | // shims for unit testing. 16 | // 17 | //nolint:gochecknoglobals 18 | var ( 19 | metadataOnGCE = metadata.OnGCE 20 | metadataProjectIDWithContext = metadata.ProjectIDWithContext 21 | metadataEmailWithContext = metadata.EmailWithContext 22 | googleFindDefaultCredentials = google.FindDefaultCredentials 23 | ) 24 | 25 | // ProjectID returns the Google Cloud Project ID of the current runtime. 26 | // Deprecated: Use the context-based [ResolveProjectID] function. 27 | func ProjectID() (string, bool) { 28 | return ResolveProjectID(context.Background()) 29 | } 30 | 31 | // ResolveProjectID resolves the Google Cloud Project ID of the current runtime. 32 | func ResolveProjectID(ctx context.Context) (string, bool) { 33 | if !metadataOnGCE() { 34 | if projectFromEnv, ok := os.LookupEnv("GOOGLE_CLOUD_PROJECT"); ok { 35 | return projectFromEnv, true 36 | } 37 | return projectIDFromDefaultCredentials(ctx) 38 | } 39 | projectID, err := metadataProjectIDWithContext(ctx) 40 | return projectID, err == nil 41 | } 42 | 43 | // ServiceAccount returns the default service account of the current runtime. 44 | // Deprecated: Use the context-based [ResolveServiceAccount] function. 45 | func ServiceAccount() (string, bool) { 46 | return ResolveServiceAccount(context.Background()) 47 | } 48 | 49 | // ResolveServiceAccount resolves the default service account of the current runtime. 50 | func ResolveServiceAccount(ctx context.Context) (string, bool) { 51 | if !metadataOnGCE() { 52 | return serviceAccountFromDefaultCredentials(ctx) 53 | } 54 | serviceAccount, err := metadataEmailWithContext(ctx, "default") 55 | return serviceAccount, err == nil 56 | } 57 | 58 | func projectIDFromDefaultCredentials(ctx context.Context) (string, bool) { 59 | defaultCredentials, err := googleFindDefaultCredentials(ctx) 60 | if err != nil { 61 | return "", false 62 | } 63 | return defaultCredentials.ProjectID, defaultCredentials.ProjectID != "" 64 | } 65 | 66 | func serviceAccountFromDefaultCredentials(ctx context.Context) (string, bool) { 67 | defaultCredentials, err := googleFindDefaultCredentials(ctx) 68 | if err != nil || defaultCredentials.JSON == nil { 69 | return "", false 70 | } 71 | var credentials struct { 72 | Type string `json:"type"` 73 | ClientEmail string `json:"client_email"` 74 | ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"` 75 | } 76 | if err := json.Unmarshal(defaultCredentials.JSON, &credentials); err != nil { 77 | return "", false 78 | } 79 | switch credentials.Type { 80 | case "service_account": 81 | return credentials.ClientEmail, credentials.ClientEmail != "" 82 | case "impersonated_service_account": 83 | sa, err := extractServiceAccountFromImpersonationURL(credentials.ServiceAccountImpersonationURL) 84 | if err != nil { 85 | return "", false 86 | } 87 | return sa, true 88 | } 89 | return "", false 90 | } 91 | 92 | // extractServiceAccountFromImpersonationURL parses a URL like 93 | // ".../serviceAccounts/sa@project.iam.gserviceaccount.com:generateAccessToken" 94 | // and returns "sa@project.iam.gserviceaccount.com". 95 | func extractServiceAccountFromImpersonationURL(urlStr string) (string, error) { 96 | u, err := url.Parse(urlStr) 97 | if err != nil { 98 | return "", err 99 | } 100 | parts := strings.Split(u.Path, "/") 101 | for i, part := range parts { 102 | if part == "serviceAccounts" && i+1 < len(parts) { 103 | sa := parts[i+1] 104 | if idx := strings.Index(sa, ":"); idx != -1 { 105 | sa = sa[:idx] 106 | } 107 | return sa, nil 108 | } 109 | } 110 | return "", fmt.Errorf("service account not found in URL") 111 | } 112 | -------------------------------------------------------------------------------- /cloudruntime/metadata_test.go: -------------------------------------------------------------------------------- 1 | package cloudruntime 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "golang.org/x/oauth2/google" 9 | "gotest.tools/v3/assert" 10 | ) 11 | 12 | func TestProjectID(t *testing.T) { 13 | t.Run("on GCE", func(t *testing.T) { 14 | const expected = "foo" 15 | withOnGCE(t, true) 16 | withMetadataProjectID(t, expected, nil) 17 | actual, ok := ProjectID() 18 | assert.Assert(t, ok) 19 | assert.Equal(t, expected, actual) 20 | }) 21 | 22 | t.Run("default credentials", func(t *testing.T) { 23 | const expected = "foo" 24 | withOnGCE(t, false) 25 | withGoogleDefaultCredentials(t, &google.Credentials{ProjectID: expected}, nil) 26 | actual, ok := ProjectID() 27 | assert.Assert(t, ok) 28 | assert.Equal(t, expected, actual) 29 | }) 30 | 31 | t.Run("missing default credentials", func(t *testing.T) { 32 | withOnGCE(t, false) 33 | withGoogleDefaultCredentials(t, nil, fmt.Errorf("not found")) 34 | actual, ok := ProjectID() 35 | assert.Assert(t, !ok) 36 | assert.Equal(t, "", actual) 37 | }) 38 | } 39 | 40 | func TestServiceAccount(t *testing.T) { 41 | t.Run("on GCE", func(t *testing.T) { 42 | const expected = "foo@bar.iam.gserviceaccount.com" 43 | withOnGCE(t, true) 44 | withMetadataEmail(t, "default", expected, nil) 45 | actual, ok := ServiceAccount() 46 | assert.Assert(t, ok) 47 | assert.Equal(t, expected, actual) 48 | }) 49 | 50 | t.Run("default credentials", func(t *testing.T) { 51 | const expected = "foo@bar.iam.gserviceaccount.com" 52 | withOnGCE(t, false) 53 | withGoogleDefaultCredentials(t, &google.Credentials{ 54 | JSON: []byte(fmt.Sprintf(`{"type":"service_account","client_email":"%s"}`, expected)), 55 | }, nil) 56 | actual, ok := ServiceAccount() 57 | assert.Assert(t, ok) 58 | assert.Equal(t, expected, actual) 59 | }) 60 | 61 | t.Run("user default credentials", func(t *testing.T) { 62 | withOnGCE(t, false) 63 | withGoogleDefaultCredentials(t, &google.Credentials{ 64 | JSON: []byte(`{"type":"user","client_email":"foo@example.com"}`), 65 | }, nil) 66 | actual, ok := ServiceAccount() 67 | assert.Assert(t, !ok) 68 | assert.Equal(t, "", actual) 69 | }) 70 | 71 | t.Run("missing default credentials", func(t *testing.T) { 72 | withOnGCE(t, false) 73 | withGoogleDefaultCredentials(t, nil, fmt.Errorf("not found")) 74 | actual, ok := ServiceAccount() 75 | assert.Assert(t, !ok) 76 | assert.Equal(t, "", actual) 77 | }) 78 | 79 | t.Run("invalid default credentials", func(t *testing.T) { 80 | withOnGCE(t, false) 81 | withGoogleDefaultCredentials(t, &google.Credentials{JSON: []byte(`foo`)}, nil) 82 | actual, ok := ServiceAccount() 83 | assert.Assert(t, !ok) 84 | assert.Equal(t, "", actual) 85 | }) 86 | } 87 | 88 | func withGoogleDefaultCredentials(t *testing.T, credentials *google.Credentials, err error) { 89 | prev := googleFindDefaultCredentials 90 | googleFindDefaultCredentials = func(_ context.Context, _ ...string) (*google.Credentials, error) { 91 | return credentials, err 92 | } 93 | t.Cleanup(func() { 94 | googleFindDefaultCredentials = prev 95 | }) 96 | } 97 | 98 | func withMetadataProjectID(t *testing.T, value string, err error) { 99 | prev := metadataProjectIDWithContext 100 | metadataProjectIDWithContext = func(context.Context) (string, error) { 101 | return value, err 102 | } 103 | t.Cleanup(func() { 104 | metadataProjectIDWithContext = prev 105 | }) 106 | } 107 | 108 | func withMetadataEmail(t *testing.T, expectedServiceAccount, value string, err error) { 109 | prev := metadataEmailWithContext 110 | metadataEmailWithContext = func(_ context.Context, serviceAccount string) (string, error) { 111 | assert.Equal(t, expectedServiceAccount, serviceAccount) 112 | return value, err 113 | } 114 | t.Cleanup(func() { 115 | metadataEmailWithContext = prev 116 | }) 117 | } 118 | 119 | func withOnGCE(t *testing.T, value bool) { 120 | prev := metadataOnGCE 121 | metadataOnGCE = func() bool { 122 | return value 123 | } 124 | t.Cleanup(func() { 125 | metadataOnGCE = prev 126 | }) 127 | } 128 | -------------------------------------------------------------------------------- /cloudruntime/version.go: -------------------------------------------------------------------------------- 1 | package cloudruntime 2 | 3 | import ( 4 | "os" 5 | "runtime/debug" 6 | ) 7 | 8 | //nolint:gochecknoglobals 9 | var serviceVersion string 10 | 11 | // ServiceVersionFromLinkerFlags returns the exact value of the variable: 12 | // 13 | // go.einride.tech/cloudrunner/cloudruntime.serviceVersion 14 | // 15 | // This variable can be set during build-time to provide a default value for the service version. 16 | // 17 | // Example: 18 | // 19 | // go build -ldflags="-X 'go.einride.tech/cloudrunner/cloudruntime.serviceVersion=v1.0.0'" 20 | func ServiceVersionFromLinkerFlags() string { 21 | return serviceVersion 22 | } 23 | 24 | // ServiceVersionFromBuildInfo returns the VCS revision from the embedded build info. 25 | func ServiceVersionFromBuildInfo() (string, bool) { 26 | buildInfo, ok := debug.ReadBuildInfo() 27 | if !ok { 28 | return "", false 29 | } 30 | for _, setting := range buildInfo.Settings { 31 | if setting.Key == "vcs.revision" { 32 | return setting.Value, true 33 | } 34 | } 35 | return "", false 36 | } 37 | 38 | // ServiceVersion returns the service version of the current runtime. 39 | // The service version is taken from, in order of precedence: 40 | // - the "SERVICE_VERSION" environment variable 41 | // - the go.einride.tech/cloudrunner/cloudruntime.serviceVersion variable (must be set at build-time) 42 | // - the "K_REVISION" environment variable 43 | // - no version. 44 | func ServiceVersion() (string, bool) { 45 | // TODO: Remove support for everything except version from build info. 46 | if serviceVersionFromEnv, ok := os.LookupEnv("SERVICE_VERSION"); ok { 47 | return serviceVersionFromEnv, ok 48 | } 49 | if ServiceVersionFromLinkerFlags() != "" { 50 | return ServiceVersionFromLinkerFlags(), true 51 | } 52 | if version, ok := ServiceVersionFromBuildInfo(); ok { 53 | return version, true 54 | } 55 | if revision, ok := os.LookupEnv("K_REVISION"); ok { 56 | return revision, true 57 | } 58 | return "", false 59 | } 60 | -------------------------------------------------------------------------------- /cloudruntime/version_test.go: -------------------------------------------------------------------------------- 1 | package cloudruntime 2 | 3 | import ( 4 | "testing" 5 | 6 | "gotest.tools/v3/assert" 7 | ) 8 | 9 | func TestServiceVersion(t *testing.T) { 10 | t.Skip("TODO: Migrate fully to debug.BuildInfo") 11 | t.Run("SERVICE_VERSION highest priority", func(t *testing.T) { 12 | setEnv(t, "SERVICE_VERSION", "foo") 13 | setServiceVersion(t, "bar") 14 | setEnv(t, "K_REVISION", "baz") 15 | actual, ok := ServiceVersion() 16 | assert.Assert(t, ok) 17 | assert.Equal(t, "foo", actual) 18 | }) 19 | 20 | t.Run("global variable second highest priority", func(t *testing.T) { 21 | setServiceVersion(t, "bar") 22 | setEnv(t, "K_REVISION", "baz") 23 | actual, ok := ServiceVersion() 24 | assert.Assert(t, ok) 25 | assert.Equal(t, "bar", actual) 26 | }) 27 | 28 | t.Run("K_REVISION third highest priority", func(t *testing.T) { 29 | setEnv(t, "K_REVISION", "baz") 30 | actual, ok := ServiceVersion() 31 | assert.Assert(t, ok) 32 | assert.Equal(t, "baz", actual) 33 | }) 34 | 35 | t.Run("no version", func(t *testing.T) { 36 | actual, ok := ServiceVersion() 37 | assert.Assert(t, !ok) 38 | assert.Equal(t, "", actual) 39 | }) 40 | } 41 | 42 | func setServiceVersion(t *testing.T, value string) { 43 | prev := serviceVersion 44 | serviceVersion = value 45 | t.Cleanup(func() { 46 | serviceVersion = prev 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /cloudserver/config.go: -------------------------------------------------------------------------------- 1 | package cloudserver 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Config provides config for gRPC and HTTP servers. 8 | type Config struct { 9 | // Timeout of all requests to the servers. 10 | // Defaults to 10 seconds below the default Cloud Run timeout for managed services. 11 | Timeout time.Duration `default:"290s"` 12 | } 13 | -------------------------------------------------------------------------------- /cloudserver/doc.go: -------------------------------------------------------------------------------- 1 | // Package cloudserver provides primitives for gRPC and HTTP servers. 2 | package cloudserver 3 | -------------------------------------------------------------------------------- /cloudserver/http.go: -------------------------------------------------------------------------------- 1 | package cloudserver 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | "runtime/debug" 9 | 10 | "go.einride.tech/cloudrunner/cloudrequestlog" 11 | ) 12 | 13 | // HTTPServer provides HTTP server middleware. 14 | func (i *Middleware) HTTPServer(next http.Handler) http.Handler { 15 | return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 16 | defer func() { 17 | if r := recover(); r != nil { 18 | writer.WriteHeader(http.StatusInternalServerError) 19 | if fields, ok := cloudrequestlog.GetAdditionalFields(request.Context()); ok { 20 | fields.Add( 21 | slog.String("stack", string(debug.Stack())), 22 | slog.Any("error", fmt.Errorf("recovered panic: %v", r)), 23 | ) 24 | } 25 | } 26 | }() 27 | if i.Config.Timeout <= 0 { 28 | next.ServeHTTP(writer, request) 29 | return 30 | } 31 | ctx, cancel := context.WithTimeout(request.Context(), i.Config.Timeout) 32 | defer cancel() 33 | next.ServeHTTP(writer, request.WithContext(ctx)) 34 | }) 35 | } 36 | 37 | // HTTPMiddleware is a HTTP middleware. 38 | type HTTPMiddleware = func(http.Handler) http.Handler 39 | 40 | // ChainHTTPMiddleware chains the HTTP handler middleware to execute from left to right. 41 | func ChainHTTPMiddleware(next http.Handler, middlewares ...HTTPMiddleware) http.Handler { 42 | if len(middlewares) == 0 { 43 | return next 44 | } 45 | wrapped := next 46 | // loop in reverse to preserve middleware order 47 | for i := len(middlewares) - 1; i >= 0; i-- { 48 | wrapped = middlewares[i](wrapped) 49 | } 50 | return wrapped 51 | } 52 | -------------------------------------------------------------------------------- /cloudserver/http_test.go: -------------------------------------------------------------------------------- 1 | package cloudserver_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "go.einride.tech/cloudrunner/cloudserver" 9 | "gotest.tools/v3/assert" 10 | ) 11 | 12 | func TestHTTPServer_RescuePanicsWithStatusInternalServerError(t *testing.T) { 13 | var handler http.Handler = http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { 14 | panic("boom!") 15 | }) 16 | res := serveHTTP(handler) 17 | assert.Equal(t, res.Code, http.StatusInternalServerError) 18 | } 19 | 20 | func TestHTTPServer_WorksWhenHeaderIsAlreadyWritten(t *testing.T) { 21 | var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 22 | w.WriteHeader(http.StatusOK) 23 | panic("boom!") 24 | }) 25 | res := serveHTTP(handler) 26 | assert.Equal(t, res.Code, http.StatusOK) 27 | } 28 | 29 | func serveHTTP(handler http.Handler) *httptest.ResponseRecorder { 30 | req := httptest.NewRequest(http.MethodGet, "http://testing.com", nil) 31 | res := httptest.NewRecorder() 32 | 33 | middleware := cloudserver.Middleware{} 34 | handler = middleware.HTTPServer(handler) 35 | handler.ServeHTTP(res, req) 36 | 37 | return res 38 | } 39 | -------------------------------------------------------------------------------- /cloudserver/middleware.go: -------------------------------------------------------------------------------- 1 | package cloudserver 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "runtime" 9 | "runtime/debug" 10 | 11 | "go.einride.tech/cloudrunner/clouderror" 12 | "go.einride.tech/cloudrunner/cloudrequestlog" 13 | "go.einride.tech/cloudrunner/cloudstream" 14 | "google.golang.org/grpc" 15 | "google.golang.org/grpc/codes" 16 | "google.golang.org/grpc/status" 17 | ) 18 | 19 | // Middleware provides standard middleware for gRPC and HTTP servers. 20 | type Middleware struct { 21 | // Config for the middleware. 22 | Config Config 23 | } 24 | 25 | // GRPCUnaryServerInterceptor implements grpc.UnaryServerInterceptor. 26 | func (i *Middleware) GRPCUnaryServerInterceptor( 27 | ctx context.Context, 28 | req interface{}, 29 | _ *grpc.UnaryServerInfo, 30 | handler grpc.UnaryHandler, 31 | ) (resp interface{}, err error) { 32 | defer func() { 33 | if r := recover(); r != nil { 34 | err = clouderror.Wrap( 35 | fmt.Errorf("recovered panic: %v", r), 36 | status.New(codes.Internal, "internal error"), 37 | ) 38 | if additionalFields, ok := cloudrequestlog.GetAdditionalFields(ctx); ok { 39 | additionalFields.Add(slog.String("stack", string(debug.Stack()))) 40 | } 41 | } 42 | }() 43 | if i.Config.Timeout <= 0 { 44 | return handler(ctx, req) 45 | } 46 | ctx, cancel := context.WithTimeout(ctx, i.Config.Timeout) 47 | defer cancel() 48 | resp, err = handler(ctx, req) 49 | if errors.Is(err, context.DeadlineExceeded) { 50 | // below call is an inline version of cloudrunner.Wrap in order to avoid circular imports 51 | return nil, clouderror.WrapCaller( 52 | err, 53 | status.New(codes.DeadlineExceeded, "context deadline exceeded"), 54 | clouderror.NewCaller(runtime.Caller(1)), 55 | ) 56 | } 57 | return resp, err 58 | } 59 | 60 | // GRPCStreamServerInterceptor implements grpc.StreamServerInterceptor. 61 | func (i *Middleware) GRPCStreamServerInterceptor( 62 | srv interface{}, 63 | ss grpc.ServerStream, 64 | _ *grpc.StreamServerInfo, 65 | handler grpc.StreamHandler, 66 | ) (err error) { 67 | defer func() { 68 | if r := recover(); r != nil { 69 | err = clouderror.Wrap( 70 | fmt.Errorf("recovered panic: %v", r), 71 | status.New(codes.Internal, "internal error"), 72 | ) 73 | if additionalFields, ok := cloudrequestlog.GetAdditionalFields(ss.Context()); ok { 74 | additionalFields.Add(slog.String("stack", string(debug.Stack()))) 75 | } 76 | } 77 | }() 78 | if i.Config.Timeout <= 0 { 79 | return handler(srv, ss) 80 | } 81 | ctx, cancel := context.WithTimeout(ss.Context(), i.Config.Timeout) 82 | defer cancel() 83 | 84 | if err := handler(srv, cloudstream.NewContextualServerStream(ctx, ss)); err != nil { 85 | if errors.Is(err, context.DeadlineExceeded) { 86 | return clouderror.WrapCaller( 87 | err, 88 | status.New(codes.DeadlineExceeded, "context deadline exceeded"), 89 | clouderror.NewCaller(runtime.Caller(1)), 90 | ) 91 | } 92 | return err 93 | } 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /cloudserver/middleware_test.go: -------------------------------------------------------------------------------- 1 | package cloudserver_test 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net" 7 | "testing" 8 | "time" 9 | 10 | testproto "github.com/grpc-ecosystem/go-grpc-middleware/testing/testproto" 11 | "go.einride.tech/cloudrunner/cloudserver" 12 | "google.golang.org/grpc" 13 | "google.golang.org/grpc/codes" 14 | "google.golang.org/grpc/credentials/insecure" 15 | "google.golang.org/grpc/status" 16 | "google.golang.org/grpc/test/bufconn" 17 | "gotest.tools/v3/assert" 18 | ) 19 | 20 | const bufSize = 1024 * 1024 21 | 22 | type Server struct { 23 | panicOnRequest bool 24 | deadlineExceeeded bool 25 | } 26 | 27 | // Ping implements mwitkow_testproto.TestServiceServer. 28 | func (s *Server) Ping(context.Context, *testproto.PingRequest) (*testproto.PingResponse, error) { 29 | if s.panicOnRequest { 30 | panic("boom!") 31 | } 32 | if s.deadlineExceeeded { 33 | return nil, context.DeadlineExceeded 34 | } 35 | return &testproto.PingResponse{}, nil 36 | } 37 | 38 | // PingEmpty implements mwitkow_testproto.TestServiceServer. 39 | func (*Server) PingEmpty(context.Context, *testproto.Empty) (*testproto.PingResponse, error) { 40 | panic("unimplemented") 41 | } 42 | 43 | // PingError implements mwitkow_testproto.TestServiceServer. 44 | func (*Server) PingError(context.Context, *testproto.PingRequest) (*testproto.Empty, error) { 45 | panic("unimplemented") 46 | } 47 | 48 | // PingList implements mwitkow_testproto.TestServiceServer. 49 | func (*Server) PingList(*testproto.PingRequest, testproto.TestService_PingListServer) error { 50 | panic("unimplemented") 51 | } 52 | 53 | // PingStream implements mwitkow_testproto.TestServiceServer. 54 | func (s *Server) PingStream(out testproto.TestService_PingStreamServer) error { 55 | if s.panicOnRequest { 56 | panic("boom!") 57 | } 58 | if s.deadlineExceeeded { 59 | return context.DeadlineExceeded 60 | } 61 | return out.Send(&testproto.PingResponse{}) 62 | } 63 | 64 | var _ testproto.TestServiceServer = &Server{} 65 | 66 | func bufDialer(lis *bufconn.Listener) func(context.Context, string) (net.Conn, error) { 67 | return func(context.Context, string) (net.Conn, error) { return lis.Dial() } 68 | } 69 | 70 | func TestGRPCUnary_ContextTimeoutWithDeadlineExceededErr(t *testing.T) { 71 | ctx := context.Background() 72 | server, client := grpcSetup(t) 73 | server.deadlineExceeeded = true 74 | 75 | _, err := client.Ping(ctx, &testproto.PingRequest{}) 76 | assert.ErrorIs(t, err, status.Error(codes.DeadlineExceeded, "context deadline exceeded")) 77 | } 78 | 79 | func TestGRPCUnary_RescuePanicsWithStatusInternalError(t *testing.T) { 80 | ctx := context.Background() 81 | server, client := grpcSetup(t) 82 | server.panicOnRequest = true 83 | 84 | _, err := client.Ping(ctx, &testproto.PingRequest{}) 85 | assert.ErrorIs(t, err, status.Error(codes.Internal, "internal error")) 86 | } 87 | 88 | func TestGRPCStream_ContextTimeoutWithDeadlineExceededErr(t *testing.T) { 89 | ctx := context.Background() 90 | server, client := grpcSetup(t) 91 | server.deadlineExceeeded = true 92 | 93 | stream, err := client.PingStream(ctx) 94 | assert.NilError(t, err) // while it looks strange, this is setting up the stream 95 | _, err = stream.Recv() 96 | assert.ErrorIs(t, err, status.Error(codes.DeadlineExceeded, "context deadline exceeded")) 97 | } 98 | 99 | func TestGRPCStream_RescuePanicsWithStatusInternalError(t *testing.T) { 100 | ctx := context.Background() 101 | server, client := grpcSetup(t) 102 | server.panicOnRequest = true 103 | 104 | stream, err := client.PingStream(ctx) 105 | assert.NilError(t, err) // while it looks strange, this is setting up the stream 106 | 107 | _, err = stream.Recv() 108 | assert.ErrorIs(t, err, status.Error(codes.Internal, "internal error")) 109 | } 110 | 111 | func TestGRPCUnary_NoRequestError(t *testing.T) { 112 | ctx := context.Background() 113 | _, client := grpcSetup(t) 114 | 115 | _, err := client.Ping(ctx, &testproto.PingRequest{}) 116 | assert.NilError(t, err) 117 | } 118 | 119 | func TestGRPCStream_NoRequestError(t *testing.T) { 120 | ctx := context.Background() 121 | _, client := grpcSetup(t) 122 | 123 | stream, err := client.PingStream(ctx) 124 | assert.NilError(t, err) // while it looks strange, this is setting up the stream 125 | 126 | _, err = stream.Recv() 127 | assert.NilError(t, err) 128 | _, err = stream.Recv() 129 | assert.Error(t, err, "EOF") 130 | } 131 | 132 | func grpcSetup(t *testing.T) (*Server, testproto.TestServiceClient) { 133 | lis := bufconn.Listen(bufSize) 134 | middleware := cloudserver.Middleware{Config: cloudserver.Config{Timeout: time.Second * 5}} 135 | server := grpc.NewServer( 136 | grpc.ChainUnaryInterceptor(middleware.GRPCUnaryServerInterceptor), 137 | grpc.ChainStreamInterceptor(middleware.GRPCStreamServerInterceptor), 138 | ) 139 | testServer := &Server{} 140 | testproto.RegisterTestServiceServer(server, testServer) 141 | go func() { 142 | if err := server.Serve(lis); err != nil { 143 | log.Fatalf("Server exited with error: %v", err) 144 | } 145 | }() 146 | conn, err := grpc.NewClient( 147 | "passthrough://bufnet", 148 | grpc.WithContextDialer(bufDialer(lis)), 149 | grpc.WithTransportCredentials(insecure.NewCredentials()), 150 | ) 151 | assert.NilError(t, err) 152 | client := testproto.NewTestServiceClient(conn) 153 | return testServer, client 154 | } 155 | -------------------------------------------------------------------------------- /cloudserver/security_headers.go: -------------------------------------------------------------------------------- 1 | package cloudserver 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // SecurityHeadersMiddleware adds security headers to responses. 8 | type SecurityHeadersMiddleware struct{} 9 | 10 | // HTTPServer provides HTTP server middleware. 11 | func (i *SecurityHeadersMiddleware) HTTPServer(next http.Handler) http.Handler { 12 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 13 | w.Header().Set("X-Content-Type-Options", "nosniff") 14 | w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") 15 | w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") 16 | next.ServeHTTP(w, r) 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /cloudslog/buildinfo.go: -------------------------------------------------------------------------------- 1 | package cloudslog 2 | 3 | import ( 4 | "log/slog" 5 | "runtime/debug" 6 | ) 7 | 8 | func newBuildInfoValue(bi *debug.BuildInfo) buildInfoValue { 9 | return buildInfoValue{BuildInfo: bi} 10 | } 11 | 12 | type buildInfoValue struct { 13 | *debug.BuildInfo 14 | } 15 | 16 | func (v buildInfoValue) LogValue() slog.Value { 17 | buildSettings := make([]any, 0, len(v.Settings)) 18 | for _, setting := range v.BuildInfo.Settings { 19 | buildSettings = append(buildSettings, slog.String(setting.Key, setting.Value)) 20 | } 21 | return slog.GroupValue( 22 | slog.String("mainPath", v.BuildInfo.Main.Path), 23 | slog.String("goVersion", v.BuildInfo.GoVersion), 24 | slog.Group("buildSettings", buildSettings...), 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /cloudslog/buildinfo_test.go: -------------------------------------------------------------------------------- 1 | package cloudslog 2 | 3 | import ( 4 | "log/slog" 5 | "runtime/debug" 6 | "strings" 7 | "testing" 8 | 9 | "gotest.tools/v3/assert" 10 | ) 11 | 12 | func TestHandler_buildInfoValue(t *testing.T) { 13 | var b strings.Builder 14 | logger := slog.New(newHandler(&b, LoggerConfig{})) 15 | buildInfo, ok := debug.ReadBuildInfo() 16 | assert.Assert(t, ok) 17 | logger.Info("test", "buildInfo", buildInfo) 18 | t.Log(b.String()) 19 | assert.Assert(t, strings.Contains(b.String(), `"buildInfo":{"mainPath":`)) 20 | } 21 | -------------------------------------------------------------------------------- /cloudslog/context.go: -------------------------------------------------------------------------------- 1 | package cloudslog 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "slices" 7 | ) 8 | 9 | type contextKey struct{} 10 | 11 | // With appends log attributes to the current parent context. 12 | // Arguments are converted to attributes as if by [slog.Logger.Log]. 13 | func With(parent context.Context, args ...any) context.Context { 14 | if v, ok := parent.Value(contextKey{}).([]slog.Attr); ok { 15 | return context.WithValue(parent, contextKey{}, append(slices.Clip(v), argsToAttrSlice(args)...)) 16 | } 17 | return context.WithValue(parent, contextKey{}, argsToAttrSlice(args)) 18 | } 19 | 20 | func attributesFromContext(ctx context.Context) []slog.Attr { 21 | if v, ok := ctx.Value(contextKey{}).([]slog.Attr); ok { 22 | return v 23 | } 24 | return nil 25 | } 26 | 27 | // argsToAttrSlice is copied from the slog stdlib. 28 | func argsToAttrSlice(args []any) []slog.Attr { 29 | var attr slog.Attr 30 | attrs := make([]slog.Attr, 0, len(args)) 31 | for len(args) > 0 { 32 | attr, args = argsToAttr(args) 33 | attrs = append(attrs, attr) 34 | } 35 | return attrs 36 | } 37 | 38 | // argsToAttr is copied from the slog stdlib. 39 | func argsToAttr(args []any) (slog.Attr, []any) { 40 | const badKey = "!BADKEY" 41 | switch x := args[0].(type) { 42 | case string: 43 | if len(args) == 1 { 44 | return slog.String(badKey, x), nil 45 | } 46 | return slog.Any(x, args[1]), args[2:] 47 | case slog.Attr: 48 | return x, args[1:] 49 | default: 50 | return slog.Any(badKey, x), args[1:] 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /cloudslog/context_test.go: -------------------------------------------------------------------------------- 1 | package cloudslog 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "strings" 7 | "testing" 8 | 9 | "gotest.tools/v3/assert" 10 | ) 11 | 12 | func TestHandler_withContextAttributes(t *testing.T) { 13 | t.Run("single", func(t *testing.T) { 14 | var b strings.Builder 15 | logger := slog.New(newHandler(&b, LoggerConfig{})) 16 | ctx := context.Background() 17 | ctx = With(ctx, "foo", "bar") 18 | logger.InfoContext(ctx, "test") 19 | assert.Assert(t, strings.Contains(b.String(), `"foo":"bar"`)) 20 | }) 21 | 22 | t.Run("attrs", func(t *testing.T) { 23 | var b strings.Builder 24 | logger := slog.New(newHandler(&b, LoggerConfig{})) 25 | ctx := context.Background() 26 | ctx = With(ctx, slog.String("foo", "bar"), slog.Int("baz", 3)) 27 | logger.InfoContext(ctx, "test") 28 | assert.Assert(t, strings.Contains(b.String(), `"foo":"bar","baz":3`)) 29 | }) 30 | 31 | t.Run("multiple", func(t *testing.T) { 32 | var b strings.Builder 33 | logger := slog.New(newHandler(&b, LoggerConfig{})) 34 | ctx := context.Background() 35 | ctx = With(ctx, "foo", "bar") 36 | ctx = With(ctx, "lorem", "ipsum") 37 | logger.InfoContext(ctx, "test") 38 | assert.Assert(t, strings.Contains(b.String(), `"foo":"bar","lorem":"ipsum"`)) 39 | }) 40 | 41 | t.Run("scoped", func(t *testing.T) { 42 | var b strings.Builder 43 | logger := slog.New(newHandler(&b, LoggerConfig{})) 44 | ctx := context.Background() 45 | ctx = With(ctx, "foo", "bar") 46 | _ = With(ctx, "lorem", "ipsum") 47 | logger.InfoContext(ctx, "test") 48 | assert.Assert(t, strings.Contains(b.String(), `"foo":"bar"`)) 49 | assert.Assert(t, !strings.Contains(b.String(), `"lorem":"ipsum"`)) 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /cloudslog/doc.go: -------------------------------------------------------------------------------- 1 | // Package cloudslog provides primitives for structured logging with the standard library log/slog package. 2 | package cloudslog 3 | -------------------------------------------------------------------------------- /cloudslog/errors.go: -------------------------------------------------------------------------------- 1 | package cloudslog 2 | 3 | import ( 4 | "encoding/json" 5 | "log/slog" 6 | ) 7 | 8 | // Errors creates a slog attribute for a list of errors. 9 | // unlike slog.Any, this will render the error strings when using slog.JSONHandler. 10 | func Errors(errors []error) slog.Attr { 11 | jsonErrors := make([]error, len(errors)) 12 | for i, err := range errors { 13 | jsonErrors[i] = &jsonError{error: err} 14 | } 15 | return slog.Any("errors", jsonErrors) 16 | } 17 | 18 | type jsonError struct { 19 | error 20 | } 21 | 22 | func (j jsonError) MarshalJSON() ([]byte, error) { 23 | return json.Marshal(j.error.Error()) 24 | } 25 | -------------------------------------------------------------------------------- /cloudslog/errors_test.go: -------------------------------------------------------------------------------- 1 | package cloudslog 2 | 3 | import ( 4 | "errors" 5 | "log/slog" 6 | "strings" 7 | "testing" 8 | 9 | "gotest.tools/v3/assert" 10 | ) 11 | 12 | func TestErrors(t *testing.T) { 13 | t.Run("errors", func(t *testing.T) { 14 | var b strings.Builder 15 | logger := slog.New(newHandler(&b, LoggerConfig{})) 16 | 17 | logger.Info("test", Errors([]error{errors.New("test_error")})) 18 | assert.Assert(t, strings.Contains(b.String(), "test_error")) 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /cloudslog/handler.go: -------------------------------------------------------------------------------- 1 | package cloudslog 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "os" 9 | "runtime/debug" 10 | 11 | "go.opentelemetry.io/otel/sdk/resource" 12 | "go.opentelemetry.io/otel/trace" 13 | ltype "google.golang.org/genproto/googleapis/logging/type" 14 | "google.golang.org/grpc/status" 15 | "google.golang.org/protobuf/proto" 16 | ) 17 | 18 | // LoggerConfig configures the application logger. 19 | type LoggerConfig struct { 20 | // ProjectID of the project the service is running in. 21 | ProjectID string 22 | // Development indicates if the logger should output human-readable output for development. 23 | Development bool `default:"true" onGCE:"false"` 24 | // Level indicates which log level the logger should output at. 25 | Level slog.Level `default:"debug" onGCE:"info"` 26 | // ProtoMessageSizeLimit is the maximum size, in bytes, of requests and responses to log. 27 | // Messages large than the limit will be truncated. 28 | // Default value, 0, means that no messages will be truncated. 29 | ProtoMessageSizeLimit int `onGCE:"1024"` 30 | } 31 | 32 | // NewHandler creates a new [slog.Handler] with special-handling for Cloud Run. 33 | func NewHandler(config LoggerConfig) slog.Handler { 34 | return newHandler(os.Stdout, config) 35 | } 36 | 37 | func newHandler(w io.Writer, config LoggerConfig) slog.Handler { 38 | replacer := &attrReplacer{config: config} 39 | var result slog.Handler 40 | if config.Development { 41 | result = slog.NewTextHandler(w, &slog.HandlerOptions{ 42 | Level: config.Level, 43 | ReplaceAttr: replacer.replaceAttr, 44 | }) 45 | } else { 46 | result = slog.NewJSONHandler(w, &slog.HandlerOptions{ 47 | AddSource: true, 48 | Level: config.Level, 49 | ReplaceAttr: replacer.replaceAttr, 50 | }) 51 | } 52 | result = &handler{Handler: result, projectID: config.ProjectID} 53 | return result 54 | } 55 | 56 | type handler struct { 57 | slog.Handler 58 | 59 | projectID string 60 | } 61 | 62 | var _ slog.Handler = &handler{} 63 | 64 | // Handle adds attributes from the span context to the [slog.Record]. 65 | func (t *handler) Handle(ctx context.Context, record slog.Record) error { 66 | if s := trace.SpanContextFromContext(ctx); s.IsValid() { 67 | // See: https://cloud.google.com/logging/docs/structured-logging#special-payload-fields 68 | if t.projectID != "" { 69 | trace := fmt.Sprintf("projects/%s/traces/%s", t.projectID, s.TraceID()) 70 | record.AddAttrs(slog.String("logging.googleapis.com/trace", trace)) 71 | } else { 72 | record.AddAttrs(slog.Any("logging.googleapis.com/trace", s.TraceID())) 73 | } 74 | record.AddAttrs(slog.Any("logging.googleapis.com/spanId", s.SpanID())) 75 | record.AddAttrs(slog.Bool("logging.googleapis.com/trace_sampled", s.TraceFlags().IsSampled())) 76 | } 77 | record.AddAttrs(attributesFromContext(ctx)...) 78 | return t.Handler.Handle(ctx, record) 79 | } 80 | 81 | type attrReplacer struct { 82 | config LoggerConfig 83 | } 84 | 85 | func (r *attrReplacer) replaceAttr(_ []string, attr slog.Attr) slog.Attr { 86 | switch attr.Key { 87 | case slog.LevelKey: 88 | // See: https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#LogSeverity 89 | attr.Key = "severity" 90 | if level := attr.Value.Any().(slog.Level); level == slog.LevelWarn { 91 | attr.Value = slog.StringValue("WARNING") 92 | } 93 | case slog.TimeKey: 94 | attr.Key = "timestamp" 95 | case slog.MessageKey: 96 | attr.Key = "message" 97 | case slog.SourceKey: 98 | attr.Key = "logging.googleapis.com/sourceLocation" 99 | } 100 | if attr.Value.Kind() == slog.KindAny { 101 | switch value := attr.Value.Any().(type) { 102 | case *resource.Resource: 103 | attr.Value = slog.AnyValue(newResourceValue(value)) 104 | case *debug.BuildInfo: 105 | attr.Value = slog.AnyValue(newBuildInfoValue(value)) 106 | case *ltype.HttpRequest: 107 | attr.Value = slog.AnyValue(newProtoValue(fixHTTPRequest(value), r.config.ProtoMessageSizeLimit)) 108 | case *status.Status: 109 | attr.Value = slog.AnyValue(newProtoValue(value.Proto(), r.config.ProtoMessageSizeLimit)) 110 | case proto.Message: 111 | if needsRedact(value) { 112 | value = proto.Clone(value) 113 | redact(value) 114 | } 115 | attr.Value = slog.AnyValue(newProtoValue(value, r.config.ProtoMessageSizeLimit)) 116 | } 117 | } 118 | return attr 119 | } 120 | -------------------------------------------------------------------------------- /cloudslog/handler_test.go: -------------------------------------------------------------------------------- 1 | package cloudslog 2 | 3 | import ( 4 | "log/slog" 5 | "strings" 6 | "testing" 7 | 8 | "gotest.tools/v3/assert" 9 | ) 10 | 11 | func TestHandler(t *testing.T) { 12 | t.Run("source", func(t *testing.T) { 13 | var b strings.Builder 14 | logger := slog.New(newHandler(&b, LoggerConfig{})) 15 | logger.Info("test") 16 | assert.Assert(t, strings.Contains(b.String(), "logging.googleapis.com/sourceLocation")) 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /cloudslog/httprequest.go: -------------------------------------------------------------------------------- 1 | package cloudslog 2 | 3 | import ( 4 | "bytes" 5 | "unicode/utf8" 6 | 7 | ltype "google.golang.org/genproto/googleapis/logging/type" 8 | "google.golang.org/protobuf/proto" 9 | ) 10 | 11 | func fixHTTPRequest(r *ltype.HttpRequest) *ltype.HttpRequest { 12 | // Fix issue with invalid UTF-8. 13 | // See: https://github.com/googleapis/google-cloud-go/issues/1383. 14 | if fixedRequestURL := fixUTF8(r.GetRequestUrl()); fixedRequestURL != r.GetRequestUrl() { 15 | r = proto.Clone(r).(*ltype.HttpRequest) 16 | r.RequestUrl = fixedRequestURL 17 | } 18 | return r 19 | } 20 | 21 | // fixUTF8 is a helper that fixes an invalid UTF-8 string by replacing 22 | // invalid UTF-8 runes with the Unicode replacement character (U+FFFD). 23 | // See: https://github.com/googleapis/google-cloud-go/issues/1383. 24 | func fixUTF8(s string) string { 25 | if utf8.ValidString(s) { 26 | return s 27 | } 28 | // Otherwise time to build the sequence. 29 | buf := new(bytes.Buffer) 30 | buf.Grow(len(s)) 31 | for _, r := range s { 32 | if utf8.ValidRune(r) { 33 | buf.WriteRune(r) 34 | } else { 35 | buf.WriteRune('\uFFFD') 36 | } 37 | } 38 | return buf.String() 39 | } 40 | -------------------------------------------------------------------------------- /cloudslog/httprequest_test.go: -------------------------------------------------------------------------------- 1 | package cloudslog 2 | 3 | import ( 4 | "log/slog" 5 | "strings" 6 | "testing" 7 | 8 | ltype "google.golang.org/genproto/googleapis/logging/type" 9 | "gotest.tools/v3/assert" 10 | ) 11 | 12 | func TestHandler_httpRequest(t *testing.T) { 13 | var b strings.Builder 14 | logger := slog.New(newHandler(&b, LoggerConfig{})) 15 | logger.Info("test", "httpRequest", <ype.HttpRequest{ 16 | RequestMethod: "GET", 17 | RequestUrl: "/foo/bar", 18 | }) 19 | assert.Assert(t, strings.Contains(b.String(), `"requestUrl":"/foo/bar"`)) 20 | } 21 | -------------------------------------------------------------------------------- /cloudslog/proto.go: -------------------------------------------------------------------------------- 1 | package cloudslog 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "google.golang.org/protobuf/encoding/protojson" 7 | "google.golang.org/protobuf/proto" 8 | ) 9 | 10 | func newProtoValue(m proto.Message, sizeLimit int) protoValue { 11 | return protoValue{Message: m, sizeLimit: sizeLimit} 12 | } 13 | 14 | type protoValue struct { 15 | proto.Message 16 | sizeLimit int 17 | } 18 | 19 | func (v protoValue) LogValue() slog.Value { 20 | if v.sizeLimit > 0 { 21 | if size := proto.Size(v.Message); size > v.sizeLimit { 22 | return slog.GroupValue( 23 | slog.String("message", "truncated due to size"), 24 | slog.Int("size", size), 25 | slog.Int("limit", v.sizeLimit), 26 | ) 27 | } 28 | } 29 | return slog.AnyValue(jsonProtoValue{Message: v.Message}) 30 | } 31 | 32 | type jsonProtoValue struct { 33 | proto.Message 34 | } 35 | 36 | func (v jsonProtoValue) MarshalJSON() ([]byte, error) { 37 | return protojson.MarshalOptions{}.Marshal(v.Message) 38 | } 39 | -------------------------------------------------------------------------------- /cloudslog/proto_test.go: -------------------------------------------------------------------------------- 1 | package cloudslog 2 | 3 | import ( 4 | "log/slog" 5 | "strings" 6 | "testing" 7 | 8 | "cloud.google.com/go/logging/apiv2/loggingpb" 9 | ltype "google.golang.org/genproto/googleapis/logging/type" 10 | "gotest.tools/v3/assert" 11 | ) 12 | 13 | func TestHandler_proto(t *testing.T) { 14 | t.Run("message", func(t *testing.T) { 15 | var b strings.Builder 16 | logger := slog.New(newHandler(&b, LoggerConfig{})) 17 | logger.Info("test", "httpRequest", <ype.HttpRequest{ 18 | RequestMethod: "GET", 19 | }) 20 | assert.Assert(t, strings.Contains(b.String(), "requestMethod")) 21 | }) 22 | 23 | t.Run("enum", func(t *testing.T) { 24 | var b strings.Builder 25 | logger := slog.New(newHandler(&b, LoggerConfig{})) 26 | logger.Info("test", "logEntry", &loggingpb.LogEntry{ 27 | Severity: ltype.LogSeverity_INFO, 28 | }) 29 | assert.Assert(t, strings.Contains(b.String(), `"severity":"INFO"`)) 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /cloudslog/redact.go: -------------------------------------------------------------------------------- 1 | package cloudslog 2 | 3 | import ( 4 | "google.golang.org/protobuf/proto" 5 | "google.golang.org/protobuf/reflect/protopath" 6 | "google.golang.org/protobuf/reflect/protorange" 7 | "google.golang.org/protobuf/reflect/protoreflect" 8 | "google.golang.org/protobuf/types/descriptorpb" 9 | ) 10 | 11 | func needsRedact(input proto.Message) bool { 12 | var result bool 13 | _ = protorange.Range(input.ProtoReflect(), func(values protopath.Values) error { 14 | last := values.Index(-1) 15 | if _, ok := last.Value.Interface().(string); !ok { 16 | return nil 17 | } 18 | if last.Step.Kind() != protopath.FieldAccessStep { 19 | return nil 20 | } 21 | if last.Step.FieldDescriptor().Options().(*descriptorpb.FieldOptions).GetDebugRedact() { 22 | result = true 23 | return protorange.Terminate 24 | } 25 | return nil 26 | }) 27 | return result 28 | } 29 | 30 | func redact(input proto.Message) { 31 | _ = protorange.Range(input.ProtoReflect(), func(values protopath.Values) error { 32 | last := values.Index(-1) 33 | if _, ok := last.Value.Interface().(string); !ok { 34 | return nil 35 | } 36 | if last.Step.Kind() != protopath.FieldAccessStep { 37 | return nil 38 | } 39 | if last.Step.FieldDescriptor().Options().(*descriptorpb.FieldOptions).GetDebugRedact() { 40 | values.Index(-2).Value.Message().Set(last.Step.FieldDescriptor(), protoreflect.ValueOfString("")) 41 | return nil 42 | } 43 | return nil 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /cloudslog/redact_test.go: -------------------------------------------------------------------------------- 1 | package cloudslog 2 | 3 | import ( 4 | "log/slog" 5 | "strings" 6 | "testing" 7 | 8 | examplev1 "go.einride.tech/protobuf-sensitive/gen/einride/sensitive/example/v1" 9 | "gotest.tools/v3/assert" 10 | ) 11 | 12 | func TestHandler_redact(t *testing.T) { 13 | t.Run("redacted field", func(t *testing.T) { 14 | var b strings.Builder 15 | logger := slog.New(newHandler(&b, LoggerConfig{})) 16 | logger.Info("test", "example", &examplev1.ExampleMessage{ 17 | DebugRedactedField: "foobar", 18 | }) 19 | assert.Assert(t, strings.Contains(b.String(), `"debugRedactedField":""`), b.String()) 20 | }) 21 | 22 | t.Run("redacted and non-redacted field", func(t *testing.T) { 23 | var b strings.Builder 24 | logger := slog.New(newHandler(&b, LoggerConfig{})) 25 | logger.Info("test", "example", &examplev1.ExampleMessage{ 26 | DebugRedactedField: "foobar", 27 | NonSensitiveField: "baz", 28 | }) 29 | assert.Assert(t, strings.Contains(b.String(), `"debugRedactedField":""`), b.String()) 30 | assert.Assert(t, strings.Contains(b.String(), `"nonSensitiveField":"baz"`), b.String()) 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /cloudslog/resource.go: -------------------------------------------------------------------------------- 1 | package cloudslog 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "go.opentelemetry.io/otel/sdk/resource" 7 | ) 8 | 9 | func newResourceValue(r *resource.Resource) resourceValue { 10 | return resourceValue{Resource: r} 11 | } 12 | 13 | type resourceValue struct { 14 | *resource.Resource 15 | } 16 | 17 | func (r resourceValue) LogValue() slog.Value { 18 | attrs := make([]slog.Attr, 0, r.Resource.Len()) 19 | it := r.Resource.Iter() 20 | for it.Next() { 21 | attr := it.Attribute() 22 | attrs = append(attrs, slog.Any(string(attr.Key), attr.Value.AsInterface())) 23 | } 24 | return slog.GroupValue(attrs...) 25 | } 26 | -------------------------------------------------------------------------------- /cloudslog/resource_test.go: -------------------------------------------------------------------------------- 1 | package cloudslog 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "strings" 7 | "testing" 8 | 9 | "go.opentelemetry.io/otel/attribute" 10 | "go.opentelemetry.io/otel/sdk/resource" 11 | "gotest.tools/v3/assert" 12 | ) 13 | 14 | func TestHandler_resource(t *testing.T) { 15 | var b strings.Builder 16 | logger := slog.New(newHandler(&b, LoggerConfig{})) 17 | r, err := resource.New(context.Background(), resource.WithAttributes( 18 | attribute.KeyValue{ 19 | Key: "foo", 20 | Value: attribute.StringValue("bar"), 21 | }, 22 | )) 23 | assert.NilError(t, err) 24 | logger.Info("test", "resource", r) 25 | assert.Assert(t, strings.Contains(b.String(), `"resource":{"foo":"bar"}`)) 26 | } 27 | -------------------------------------------------------------------------------- /cloudstatus/code.go: -------------------------------------------------------------------------------- 1 | package cloudstatus 2 | 3 | import ( 4 | "net/http" 5 | 6 | "google.golang.org/grpc/codes" 7 | ) 8 | 9 | // ToHTTP converts a gRPC error code into the corresponding HTTP response status. 10 | // See: https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto 11 | // From: https://github.com/grpc-ecosystem/grpc-gateway/blob/master/runtime/errors.go 12 | func ToHTTP(code codes.Code) int { 13 | switch code { 14 | case codes.OK: 15 | return http.StatusOK 16 | case codes.Canceled: 17 | return http.StatusRequestTimeout 18 | case codes.Unknown: 19 | return http.StatusInternalServerError 20 | case codes.InvalidArgument: 21 | return http.StatusBadRequest 22 | case codes.DeadlineExceeded: 23 | return http.StatusGatewayTimeout 24 | case codes.NotFound: 25 | return http.StatusNotFound 26 | case codes.AlreadyExists: 27 | return http.StatusConflict 28 | case codes.PermissionDenied: 29 | return http.StatusForbidden 30 | case codes.Unauthenticated: 31 | return http.StatusUnauthorized 32 | case codes.ResourceExhausted: 33 | return http.StatusTooManyRequests 34 | case codes.FailedPrecondition: 35 | // This deliberately doesn't translate to the similarly named '412 Precondition Failed' HTTP response status. 36 | return http.StatusBadRequest 37 | case codes.Aborted: 38 | return http.StatusConflict 39 | case codes.OutOfRange: 40 | return http.StatusBadRequest 41 | case codes.Unimplemented: 42 | return http.StatusNotImplemented 43 | case codes.Internal: 44 | return http.StatusInternalServerError 45 | case codes.Unavailable: 46 | return http.StatusServiceUnavailable 47 | case codes.DataLoss: 48 | return http.StatusInternalServerError 49 | default: 50 | return http.StatusInternalServerError 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /cloudstream/middleware.go: -------------------------------------------------------------------------------- 1 | package cloudstream 2 | 3 | import ( 4 | "context" 5 | 6 | "google.golang.org/grpc" 7 | ) 8 | 9 | // ContextualServerStream wraps a "normal" grpc.Server stream but replaces the context. 10 | // This is useful in for example middlewares. 11 | type ContextualServerStream struct { 12 | grpc.ServerStream 13 | ctx context.Context 14 | } 15 | 16 | // NewContextualServerStream creates a new server stream, that uses the given context. 17 | func NewContextualServerStream(ctx context.Context, root grpc.ServerStream) *ContextualServerStream { 18 | return &ContextualServerStream{ 19 | ServerStream: root, 20 | ctx: ctx, 21 | } 22 | } 23 | 24 | // Context returns the context for this server stream. 25 | func (s *ContextualServerStream) Context() context.Context { 26 | return s.ctx 27 | } 28 | -------------------------------------------------------------------------------- /cloudtesting/doc.go: -------------------------------------------------------------------------------- 1 | // Package cloudtesting provides testing utilities. 2 | package cloudtesting 3 | -------------------------------------------------------------------------------- /cloudtesting/trace.go: -------------------------------------------------------------------------------- 1 | package cloudtesting 2 | 3 | import ( 4 | "context" 5 | 6 | cloudtrace "go.einride.tech/cloudrunner/cloudtrace" 7 | "google.golang.org/grpc/metadata" 8 | ) 9 | 10 | // WithIncomingTraceContext returns a new context with the specified trace. 11 | // Deprecated: use opentelemetry trace.ContextWithSpanContext instead. 12 | func WithIncomingTraceContext(ctx context.Context, traceContext cloudtrace.Context) context.Context { 13 | md, _ := metadata.FromIncomingContext(ctx) 14 | return metadata.NewIncomingContext( 15 | ctx, 16 | metadata.Join(md, metadata.Pairs(cloudtrace.ContextHeader, traceContext.String())), 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /cloudtrace/context.go: -------------------------------------------------------------------------------- 1 | package cloudtrace 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // Context represents a Google Cloud Trace context header value. 9 | // 10 | // The format of the X-Cloud-Trace-Context header is: 11 | // 12 | // TRACE_ID/SPAN_ID;o=TRACE_TRUE" 13 | // 14 | // See: https://cloud.google.com/trace/docs/setup 15 | type Context struct { 16 | // TraceID is a 32-character hexadecimal value representing a 128-bit number. 17 | TraceID string 18 | // SpanID is the decimal representation of the (unsigned) span ID. 19 | SpanID string 20 | // Sampled indicates if the trace is being sampled. 21 | Sampled bool 22 | } 23 | 24 | // UnmarshalString parses the provided X-Cloud-Trace-Context header. 25 | func (c *Context) UnmarshalString(value string) error { 26 | if len(value) == 0 { 27 | return fmt.Errorf("empty %s", ContextHeader) 28 | } 29 | indexOfSlash := strings.IndexByte(value, '/') 30 | if indexOfSlash == -1 { 31 | c.TraceID = value 32 | if len(c.TraceID) != 32 { 33 | return fmt.Errorf("invalid %s '%s': trace ID is not a 32-character hex value", ContextHeader, value) 34 | } 35 | return nil 36 | } 37 | c.TraceID = value[:indexOfSlash] 38 | if len(c.TraceID) != 32 { 39 | return fmt.Errorf("invalid %s '%s': trace ID is not a 32-character hex value", ContextHeader, value) 40 | } 41 | indexOfSemicolon := strings.IndexByte(value, ';') 42 | if indexOfSemicolon == -1 { 43 | c.SpanID = value[indexOfSlash+1:] 44 | return nil 45 | } 46 | c.SpanID = value[indexOfSlash+1 : indexOfSemicolon] 47 | switch value[indexOfSemicolon+1:] { 48 | case "o=1": 49 | c.Sampled = true 50 | case "o=0": 51 | c.Sampled = false 52 | default: 53 | return fmt.Errorf("invalid %s '%s'", ContextHeader, value) 54 | } 55 | return nil 56 | } 57 | 58 | // String returns a string representation of the trace context. 59 | func (c Context) String() string { 60 | sampled := 0 61 | if c.Sampled { 62 | sampled = 1 63 | } 64 | return fmt.Sprintf("%s/%s;o=%d", c.TraceID, c.SpanID, sampled) 65 | } 66 | -------------------------------------------------------------------------------- /cloudtrace/context_test.go: -------------------------------------------------------------------------------- 1 | package cloudtrace 2 | 3 | import ( 4 | "testing" 5 | 6 | "gotest.tools/v3/assert" 7 | ) 8 | 9 | func TestContext_UnmarshalString(t *testing.T) { 10 | for _, tt := range []struct { 11 | name string 12 | input string 13 | expected Context 14 | errorContains string 15 | }{ 16 | { 17 | name: "ok", 18 | input: "105445aa7843bc8bf206b12000100000/1;o=1", 19 | expected: Context{ 20 | TraceID: "105445aa7843bc8bf206b12000100000", 21 | SpanID: "1", 22 | Sampled: true, 23 | }, 24 | }, 25 | 26 | { 27 | name: "empty", 28 | input: "", 29 | errorContains: "empty x-cloud-trace-context", 30 | }, 31 | 32 | { 33 | name: "invalid", 34 | input: "foo", 35 | errorContains: "invalid x-cloud-trace-context 'foo': trace ID is not a 32-character hex value", 36 | }, 37 | 38 | { 39 | name: "only trace ID", 40 | input: "105445aa7843bc8bf206b12000100000", 41 | expected: Context{ 42 | TraceID: "105445aa7843bc8bf206b12000100000", 43 | }, 44 | }, 45 | 46 | { 47 | name: "missing sampled", 48 | input: "105445aa7843bc8bf206b12000100000/1", 49 | expected: Context{ 50 | TraceID: "105445aa7843bc8bf206b12000100000", 51 | SpanID: "1", 52 | }, 53 | }, 54 | 55 | { 56 | name: "malformed sampled", 57 | input: "105445aa7843bc8bf206b12000100000/1;foo", 58 | errorContains: "invalid x-cloud-trace-context '105445aa7843bc8bf206b12000100000/1;foo'", 59 | }, 60 | } { 61 | t.Run(tt.name, func(t *testing.T) { 62 | var actual Context 63 | err := actual.UnmarshalString(tt.input) 64 | if tt.errorContains != "" { 65 | assert.ErrorContains(t, err, tt.errorContains) 66 | } else { 67 | assert.NilError(t, err) 68 | assert.Equal(t, tt.expected, actual) 69 | } 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /cloudtrace/doc.go: -------------------------------------------------------------------------------- 1 | // Package cloudtrace provides primitives for Cloud Trace integration. 2 | // 3 | // Deprecated: Google Cloud now officially supports the W3C standard for trace-context in their various products 4 | // and recommends using that instead. On top of that OpenTelemetry now also has official support for tracing so we 5 | // recommend using opentelemetry package for working with traces. 6 | package cloudtrace 7 | -------------------------------------------------------------------------------- /cloudtrace/exporter.go: -------------------------------------------------------------------------------- 1 | package cloudtrace 2 | 3 | import ( 4 | "context" 5 | 6 | "go.einride.tech/cloudrunner/cloudotel" 7 | "go.opentelemetry.io/otel/sdk/resource" 8 | ) 9 | 10 | // Deprecated: use cloudotel.TraceExporterConfig. 11 | type ExporterConfig = cloudotel.TraceExporterConfig 12 | 13 | // StartExporter starts the OpenTelemetry Cloud Trace exporter. 14 | // Deprecated: use cloudotel.StartTraceExporter. 15 | func StartExporter( 16 | ctx context.Context, 17 | exporterConfig ExporterConfig, 18 | resource *resource.Resource, 19 | ) (func(context.Context) error, error) { 20 | return cloudotel.StartTraceExporter(ctx, exporterConfig, resource) 21 | } 22 | -------------------------------------------------------------------------------- /cloudtrace/idhook.go: -------------------------------------------------------------------------------- 1 | package cloudtrace 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "go.einride.tech/cloudrunner/cloudslog" 8 | ) 9 | 10 | // IDKey is the log entry key for trace IDs. 11 | // Experimental: May be removed in a future update. 12 | const IDKey = "traceId" 13 | 14 | // IDHook adds the trace ID (without the full trace resource name) to the request logger. 15 | // The trace ID can be used to filter on logs for the same trace across multiple projects. 16 | // Experimental: May be removed in a future update. 17 | func IDHook(ctx context.Context, traceContext Context) context.Context { 18 | return cloudslog.With(ctx, slog.String(IDKey, traceContext.TraceID)) 19 | } 20 | -------------------------------------------------------------------------------- /cloudtrace/metadata.go: -------------------------------------------------------------------------------- 1 | package cloudtrace 2 | 3 | import ( 4 | "context" 5 | 6 | "google.golang.org/grpc/metadata" 7 | ) 8 | 9 | // ContextHeader is the metadata key of the Cloud Trace context header. 10 | const ContextHeader = "x-cloud-trace-context" 11 | 12 | // FromIncomingContext returns the incoming Cloud Trace Context. 13 | // Deprecated: FromIncomingContext does not handle trace context coming from a HTTP server, 14 | // use GetContext instead. 15 | func FromIncomingContext(ctx context.Context) (Context, bool) { 16 | md, ok := metadata.FromIncomingContext(ctx) 17 | if !ok { 18 | return Context{}, false 19 | } 20 | values := md.Get(ContextHeader) 21 | if len(values) != 1 { 22 | return Context{}, false 23 | } 24 | var result Context 25 | if err := result.UnmarshalString(values[0]); err != nil { 26 | return Context{}, false 27 | } 28 | return result, true 29 | } 30 | 31 | type contextKey struct{} 32 | 33 | // SetContext sets the cloud trace context to the provided context. 34 | // Deprecated: Use OpenTelemetry middleware for trace extraction. 35 | func SetContext(ctx context.Context, ctxx Context) context.Context { 36 | return context.WithValue(ctx, contextKey{}, ctxx) 37 | } 38 | 39 | // GetContext gets the cloud trace context from the provided context if it exists. 40 | // Deprecated: Use OpenTelemetry trace.SpanContextFromContext. 41 | func GetContext(ctx context.Context) (Context, bool) { 42 | result, ok := ctx.Value(contextKey{}).(Context) 43 | if !ok { 44 | return Context{}, false 45 | } 46 | return result, true 47 | } 48 | -------------------------------------------------------------------------------- /cloudtrace/metadata_test.go: -------------------------------------------------------------------------------- 1 | package cloudtrace 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "gotest.tools/v3/assert" 8 | ) 9 | 10 | func Test_Context(t *testing.T) { 11 | t.Parallel() 12 | t.Run("read from empty context", func(t *testing.T) { 13 | t.Parallel() 14 | ctx := context.Background() 15 | value, ok := GetContext(ctx) 16 | assert.Equal(t, false, ok) 17 | assert.DeepEqual(t, Context{}, value) 18 | }) 19 | t.Run("set and read from context", func(t *testing.T) { 20 | t.Parallel() 21 | ctx := context.Background() 22 | ctx = SetContext(ctx, Context{ 23 | TraceID: "traceId", 24 | SpanID: "spanId", 25 | Sampled: true, 26 | }) 27 | value, ok := GetContext(ctx) 28 | assert.Equal(t, true, ok) 29 | assert.DeepEqual( 30 | t, 31 | Context{ 32 | TraceID: "traceId", 33 | SpanID: "spanId", 34 | Sampled: true, 35 | }, 36 | value, 37 | ) 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /cloudtrace/middleware.go: -------------------------------------------------------------------------------- 1 | package cloudtrace 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "go.einride.tech/cloudrunner/cloudstream" 8 | "go.einride.tech/cloudrunner/cloudzap" 9 | "go.uber.org/zap" 10 | "google.golang.org/grpc" 11 | "google.golang.org/grpc/metadata" 12 | ) 13 | 14 | // Middleware that ensures incoming traces are forwarded and included in logging. 15 | type Middleware struct { 16 | // ProjectID of the project the service is running in. 17 | ProjectID string 18 | // TraceHook is an optional callback that gets called with the parsed trace context. 19 | TraceHook func(context.Context, Context) context.Context 20 | } 21 | 22 | // GRPCServerUnaryInterceptor provides unary RPC middleware for gRPC servers. 23 | func (i *Middleware) GRPCServerUnaryInterceptor( 24 | ctx context.Context, 25 | req interface{}, 26 | _ *grpc.UnaryServerInfo, 27 | handler grpc.UnaryHandler, 28 | ) (resp interface{}, err error) { 29 | md, ok := metadata.FromIncomingContext(ctx) 30 | if !ok { 31 | return handler(ctx, req) 32 | } 33 | values := md.Get(ContextHeader) 34 | if len(values) != 1 { 35 | return handler(ctx, req) 36 | } 37 | ctx = i.withOutgoingRequestTracing(ctx, values[0]) 38 | ctx = i.withInternalContext(ctx, values[0]) 39 | ctx = i.withLogTracing(ctx, values[0]) 40 | return handler(ctx, req) 41 | } 42 | 43 | // GRPCStreamServerInterceptor adds tracing metadata to streaming RPCs. 44 | func (i *Middleware) GRPCStreamServerInterceptor( 45 | srv interface{}, 46 | ss grpc.ServerStream, 47 | _ *grpc.StreamServerInfo, 48 | handler grpc.StreamHandler, 49 | ) (err error) { 50 | md, ok := metadata.FromIncomingContext(ss.Context()) 51 | if !ok { 52 | return handler(srv, ss) 53 | } 54 | values := md.Get(ContextHeader) 55 | if len(values) != 1 { 56 | return handler(srv, ss) 57 | } 58 | ctx := ss.Context() 59 | ctx = i.withOutgoingRequestTracing(ctx, values[0]) 60 | ctx = i.withInternalContext(ctx, values[0]) 61 | ctx = i.withLogTracing(ctx, values[0]) 62 | return handler(srv, cloudstream.NewContextualServerStream(ctx, ss)) 63 | } 64 | 65 | // HTTPServer provides middleware for HTTP servers. 66 | func (i *Middleware) HTTPServer(next http.Handler) http.Handler { 67 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 68 | header := r.Header.Get(ContextHeader) 69 | if header == "" { 70 | next.ServeHTTP(w, r) 71 | return 72 | } 73 | w.Header().Set(ContextHeader, header) 74 | ctx := i.withOutgoingRequestTracing(r.Context(), header) 75 | ctx = i.withInternalContext(ctx, header) 76 | ctx = i.withLogTracing(ctx, header) 77 | next.ServeHTTP(w, r.WithContext(ctx)) 78 | }) 79 | } 80 | 81 | func (i *Middleware) withOutgoingRequestTracing(ctx context.Context, header string) context.Context { 82 | return metadata.AppendToOutgoingContext(ctx, ContextHeader, header) 83 | } 84 | 85 | func (i *Middleware) withInternalContext(ctx context.Context, header string) context.Context { 86 | var result Context 87 | if err := result.UnmarshalString(header); err != nil { 88 | return ctx 89 | } 90 | return SetContext(ctx, result) 91 | } 92 | 93 | func (i *Middleware) withLogTracing(ctx context.Context, header string) context.Context { 94 | var traceContext Context 95 | if err := traceContext.UnmarshalString(header); err != nil { 96 | return ctx 97 | } 98 | if i.TraceHook != nil { 99 | ctx = i.TraceHook(ctx, traceContext) 100 | } 101 | fields := make([]zap.Field, 0, 3) 102 | fields = append(fields, cloudzap.Trace(i.ProjectID, traceContext.TraceID)) 103 | if traceContext.SpanID != "" { 104 | fields = append(fields, cloudzap.SpanID(traceContext.SpanID)) 105 | } 106 | if traceContext.Sampled { 107 | fields = append(fields, cloudzap.TraceSampled(traceContext.Sampled)) 108 | } 109 | return cloudzap.WithLoggerFields(ctx, fields...) 110 | } 111 | -------------------------------------------------------------------------------- /cloudzap/context.go: -------------------------------------------------------------------------------- 1 | package cloudzap 2 | 3 | import ( 4 | "context" 5 | 6 | "go.uber.org/zap" 7 | ) 8 | 9 | type loggerContextKey struct{} 10 | 11 | // WithLogger adds a logger to the current context. 12 | func WithLogger(ctx context.Context, logger *zap.Logger) context.Context { 13 | return context.WithValue(ctx, loggerContextKey{}, logger) 14 | } 15 | 16 | // GetLogger returns the logger for the current context. 17 | func GetLogger(ctx context.Context) (*zap.Logger, bool) { 18 | logger, ok := ctx.Value(loggerContextKey{}).(*zap.Logger) 19 | return logger, ok 20 | } 21 | 22 | // WithLoggerFields attaches structured fields to a new logger in the returned child context. 23 | func WithLoggerFields(ctx context.Context, fields ...zap.Field) context.Context { 24 | logger, ok := ctx.Value(loggerContextKey{}).(*zap.Logger) 25 | if !ok { 26 | return ctx 27 | } 28 | return WithLogger(ctx, logger.With(fields...)) 29 | } 30 | -------------------------------------------------------------------------------- /cloudzap/doc.go: -------------------------------------------------------------------------------- 1 | // Package cloudzap provides primitives for structured logging with go.uber.org/zap. 2 | package cloudzap 3 | -------------------------------------------------------------------------------- /cloudzap/encoderconfig.go: -------------------------------------------------------------------------------- 1 | package cloudzap 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "go.uber.org/zap/zapcore" 8 | ) 9 | 10 | // NewEncoderConfig creates a new zapcore.EncoderConfig for structured JSON logging to Cloud Logging. 11 | // See: https://cloud.google.com/logging/docs/agent/logging/configuration#special-fields. 12 | func NewEncoderConfig() zapcore.EncoderConfig { 13 | return zapcore.EncoderConfig{ 14 | TimeKey: "time", 15 | LevelKey: "severity", 16 | NameKey: "logger", 17 | // Omit caller and log structured source location instead. 18 | // See: https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#LogEntrySourceLocation 19 | CallerKey: "", 20 | MessageKey: "message", 21 | StacktraceKey: "stacktrace", 22 | LineEnding: zapcore.DefaultLineEnding, 23 | EncodeTime: zapcore.RFC3339NanoTimeEncoder, 24 | EncodeDuration: func(duration time.Duration, encoder zapcore.PrimitiveArrayEncoder) { 25 | encoder.AppendString(fmt.Sprintf("%gs", duration.Seconds())) 26 | }, 27 | EncodeLevel: func(level zapcore.Level, encoder zapcore.PrimitiveArrayEncoder) { 28 | encoder.AppendString(LevelToSeverity(level)) 29 | }, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /cloudzap/errorreport.go: -------------------------------------------------------------------------------- 1 | package cloudzap 2 | 3 | import ( 4 | "runtime" 5 | 6 | "go.uber.org/zap" 7 | "go.uber.org/zap/zapcore" 8 | ) 9 | 10 | const ( 11 | errorReportContextKey = "context" 12 | errorReportServiceContextKey = "serviceContext" 13 | ) 14 | 15 | // ErrorReportContextForCaller returns a structured logging field for error report context for the provided caller. 16 | func ErrorReportContextForCaller(caller zapcore.EntryCaller) zapcore.Field { 17 | return ErrorReportContextForSourceLocation(caller.PC, caller.File, caller.Line, caller.Defined) 18 | } 19 | 20 | // ErrorReportContextForSourceLocation returns an error report context structured logging field for a source location. 21 | func ErrorReportContextForSourceLocation(pc uintptr, file string, line int, ok bool) zapcore.Field { 22 | if !ok { 23 | return zap.Skip() 24 | } 25 | return ErrorReportContext(file, line, runtime.FuncForPC(pc).Name()) 26 | } 27 | 28 | // ErrorReportContext returns a structured logging field for error report context for the provided caller. 29 | func ErrorReportContext(file string, line int, function string) zapcore.Field { 30 | return zap.Object(errorReportContextKey, errorReportContext{ 31 | reportLocation: errorReportLocation{ 32 | filePath: file, 33 | line: line, 34 | functionName: function, 35 | }, 36 | }) 37 | } 38 | 39 | // ErrorReportServiceContext returns a structured logging field for error report context for the provided caller. 40 | func ErrorReportServiceContext(serviceName, serviceVersion string) zapcore.Field { 41 | return zap.Object(errorReportServiceContextKey, errorReportServiceContext{ 42 | name: serviceName, 43 | version: serviceVersion, 44 | }) 45 | } 46 | 47 | type errorReportServiceContext struct { 48 | name string 49 | version string 50 | } 51 | 52 | func (s errorReportServiceContext) MarshalLogObject(encoder zapcore.ObjectEncoder) error { 53 | encoder.AddString("name", s.name) 54 | encoder.AddString("version", s.version) 55 | return nil 56 | } 57 | 58 | type errorReportContext struct { 59 | reportLocation errorReportLocation 60 | } 61 | 62 | func (c errorReportContext) MarshalLogObject(encoder zapcore.ObjectEncoder) error { 63 | return encoder.AddObject("reportLocation", c.reportLocation) 64 | } 65 | 66 | type errorReportLocation struct { 67 | filePath string 68 | line int 69 | functionName string 70 | } 71 | 72 | func (l errorReportLocation) MarshalLogObject(enc zapcore.ObjectEncoder) error { 73 | enc.AddString("filePath", l.filePath) 74 | enc.AddInt("lineNumber", l.line) 75 | enc.AddString("functionName", l.functionName) 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /cloudzap/httprequest.go: -------------------------------------------------------------------------------- 1 | package cloudzap 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "time" 8 | "unicode/utf8" 9 | 10 | "go.uber.org/zap" 11 | "go.uber.org/zap/zapcore" 12 | ) 13 | 14 | // HTTPRequest creates a new zap.Field for a Cloud Logging HTTP request. 15 | // See: https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#HttpRequest 16 | func HTTPRequest(r *HTTPRequestObject) zap.Field { 17 | return zap.Object("httpRequest", r) 18 | } 19 | 20 | // HTTPRequestObject is a common message for logging HTTP requests. 21 | // See: https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#HttpRequest 22 | type HTTPRequestObject struct { 23 | // RequestMethod is the request method. Examples: "GET", "HEAD", "PUT", "POST". 24 | RequestMethod string 25 | // RequestURL is the scheme (http, https), the host name, the path and the query portion of the URL 26 | // that was requested. Example: "http://example.com/some/info?color=red". 27 | RequestURL string 28 | // The size of the HTTP request message in bytes, including the request headers and the request body. 29 | RequestSize int 30 | // Status is the response code indicating the status of response. Examples: 200, 404. 31 | Status int 32 | // ResponseSize is the size of the HTTP response message sent back to the client, in bytes, 33 | // including the response headers and the response body. 34 | ResponseSize int 35 | // UserAgent is the user agent sent by the client. 36 | // Example: "Mozilla/4.0 (compatible; MSIE 6.0; Windows 98; Q312461; .NET CLR 1.0.3705)". 37 | UserAgent string 38 | // RemoteIP is the IP address (IPv4 or IPv6) of the client that issued the HTTP request. 39 | // This field can include port information. 40 | // Examples: "192.168.1.1", "10.0.0.1:80", "FE80::0202:B3FF:FE1E:8329". 41 | RemoteIP string 42 | // ServerIP is the IP address (IPv4 or IPv6) of the origin server that the request was sent to. 43 | // This field can include port information. 44 | // Examples: "192.168.1.1", "10.0.0.1:80", "FE80::0202:B3FF:FE1E:8329". 45 | ServerIP string 46 | // Referer is the referer URL of the request, as defined in HTTP/1.1 Header Field Definitions. 47 | Referer string 48 | // Latency is the request processing latency on the server, from the time the request was received 49 | // until the response was sent. 50 | Latency time.Duration 51 | // Protocol is the protocol used for the request. Examples: "HTTP/1.1", "HTTP/2", "websocket" 52 | Protocol string 53 | } 54 | 55 | // MarshalLogObject implements zapcore.ObjectMarshaler. 56 | func (h *HTTPRequestObject) MarshalLogObject(encoder zapcore.ObjectEncoder) error { 57 | if h.RequestMethod != "" { 58 | encoder.AddString("requestMethod", h.RequestMethod) 59 | } 60 | if h.RequestURL != "" { 61 | encoder.AddString("requestUrl", fixUTF8(h.RequestURL)) 62 | } 63 | if h.RequestSize > 0 { 64 | addInt(encoder, "requestSize", h.RequestSize) 65 | } 66 | if h.Status != 0 { 67 | encoder.AddInt("status", h.Status) 68 | } 69 | if h.ResponseSize > 0 { 70 | addInt(encoder, "responseSize", h.ResponseSize) 71 | } 72 | if h.UserAgent != "" { 73 | encoder.AddString("userAgent", h.UserAgent) 74 | } 75 | if h.RemoteIP != "" { 76 | encoder.AddString("remoteIp", h.RemoteIP) 77 | } 78 | if h.ServerIP != "" { 79 | encoder.AddString("serverIp", h.ServerIP) 80 | } 81 | if h.Referer != "" { 82 | encoder.AddString("referer", h.Referer) 83 | } 84 | if h.Latency > 0 { 85 | addDuration(encoder, "latency", h.Latency) 86 | } 87 | if h.Protocol != "" { 88 | encoder.AddString("protocol", h.Protocol) 89 | } 90 | return nil 91 | } 92 | 93 | func addDuration(encoder zapcore.ObjectEncoder, key string, d time.Duration) { 94 | // A duration in seconds with up to nine fractional digits, terminated by 's'. Example: "3.5s". 95 | encoder.AddString(key, fmt.Sprintf("%fs", d.Seconds())) 96 | } 97 | 98 | func addInt(encoder zapcore.ObjectEncoder, key string, i int) { 99 | encoder.AddString(key, strconv.Itoa(i)) 100 | } 101 | 102 | // fixUTF8 is copied from cloud.google.com/logging/internal and fixes invalid UTF-8 strings. 103 | // See: https://github.com/googleapis/google-cloud-go/issues/1383 104 | func fixUTF8(s string) string { 105 | if utf8.ValidString(s) { 106 | return s 107 | } 108 | var buf strings.Builder 109 | buf.Grow(len(s)) 110 | for _, r := range s { 111 | if utf8.ValidRune(r) { 112 | buf.WriteRune(r) 113 | } else { 114 | buf.WriteRune('\uFFFD') 115 | } 116 | } 117 | return buf.String() 118 | } 119 | -------------------------------------------------------------------------------- /cloudzap/level.go: -------------------------------------------------------------------------------- 1 | package cloudzap 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "go.uber.org/zap/zapcore" 7 | ) 8 | 9 | // LevelToSeverity converts a zapcore.Level to its corresponding Cloud Logging severity level. 10 | // See: https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity. 11 | func LevelToSeverity(l zapcore.Level) string { 12 | switch l { 13 | case zapcore.DebugLevel: 14 | return "DEBUG" 15 | case zapcore.InfoLevel: 16 | return "INFO" 17 | case zapcore.WarnLevel: 18 | return "WARNING" 19 | case zapcore.ErrorLevel: 20 | return "ERROR" 21 | case zapcore.DPanicLevel: 22 | return "CRITICAL" 23 | case zapcore.PanicLevel: 24 | return "ALERT" 25 | case zapcore.FatalLevel: 26 | return "EMERGENCY" 27 | default: 28 | return "DEFAULT" 29 | } 30 | } 31 | 32 | // LevelToSlog converts a [zapcore.Level] to a [slog.Level]. 33 | func LevelToSlog(l zapcore.Level) slog.Level { 34 | switch l { 35 | case zapcore.DebugLevel: 36 | return slog.LevelDebug 37 | case zapcore.InfoLevel: 38 | return slog.LevelInfo 39 | case zapcore.WarnLevel: 40 | return slog.LevelWarn 41 | case zapcore.ErrorLevel, zapcore.DPanicLevel, zapcore.PanicLevel, zapcore.FatalLevel: 42 | return slog.LevelError 43 | default: 44 | return slog.LevelDebug 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /cloudzap/logger.go: -------------------------------------------------------------------------------- 1 | package cloudzap 2 | 3 | import ( 4 | "fmt" 5 | 6 | "go.einride.tech/cloudrunner/cloudruntime" 7 | "go.uber.org/zap" 8 | "go.uber.org/zap/zapcore" 9 | ) 10 | 11 | // LoggerConfig configures the application logger. 12 | type LoggerConfig struct { 13 | // Development indicates if the logger should output human-readable output for development. 14 | Development bool `default:"true" onGCE:"false"` 15 | // Level indicates which log level the logger should output at. 16 | Level zapcore.Level `default:"debug" onGCE:"info"` 17 | // ReportErrors indicates if error reports should be logged for errors. 18 | ReportErrors bool `onGCE:"true"` 19 | } 20 | 21 | // NewLogger creates a new Logger. 22 | func NewLogger(config LoggerConfig) (*zap.Logger, error) { 23 | if config.Development { 24 | zapConfig := zap.NewDevelopmentConfig() 25 | zapConfig.EncoderConfig.EncodeLevel = zapcore.LowercaseColorLevelEncoder 26 | zapConfig.Level = zap.NewAtomicLevelAt(config.Level) 27 | return zapConfig.Build( 28 | zap.AddCaller(), 29 | zap.AddStacktrace(zap.FatalLevel), // add stacktraces manually where needed 30 | ) 31 | } 32 | zapConfig := zap.NewProductionConfig() 33 | zapConfig.EncoderConfig = NewEncoderConfig() 34 | zapConfig.Level = zap.NewAtomicLevelAt(config.Level) 35 | zapOptions := []zap.Option{ 36 | zap.AddCaller(), 37 | zap.AddStacktrace(zap.FatalLevel), // add stacktraces manually where needed 38 | zap.WrapCore(func(core zapcore.Core) zapcore.Core { 39 | return sourceLocationCore{nextCore: core} 40 | }), 41 | } 42 | if config.ReportErrors { 43 | if service, ok := cloudruntime.Service(); ok { 44 | if serviceVersion, ok := cloudruntime.ServiceVersion(); ok { 45 | zapOptions = append(zapOptions, zap.WrapCore(func(core zapcore.Core) zapcore.Core { 46 | return errorReportingCore{ 47 | nextCore: core, 48 | serviceName: service, 49 | serviceVersion: serviceVersion, 50 | } 51 | })) 52 | } 53 | } 54 | } 55 | logger, err := zapConfig.Build(zapOptions...) 56 | if err != nil { 57 | return nil, fmt.Errorf("init logger: %w", err) 58 | } 59 | return logger, nil 60 | } 61 | 62 | type sourceLocationCore struct { 63 | nextCore zapcore.Core 64 | } 65 | 66 | func (c sourceLocationCore) Enabled(level zapcore.Level) bool { 67 | return c.nextCore.Enabled(level) 68 | } 69 | 70 | func (c sourceLocationCore) With(fields []zapcore.Field) zapcore.Core { 71 | return sourceLocationCore{ 72 | nextCore: c.nextCore.With(fields), 73 | } 74 | } 75 | 76 | func (c sourceLocationCore) Sync() error { 77 | return c.nextCore.Sync() 78 | } 79 | 80 | // Check implements zapcore.Core. 81 | func (c sourceLocationCore) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry { 82 | if !c.nextCore.Enabled(entry.Level) { 83 | return checked 84 | } 85 | return checked.AddCore(entry, c) 86 | } 87 | 88 | // Write implements zapcore.Core. 89 | func (c sourceLocationCore) Write(entry zapcore.Entry, fields []zapcore.Field) error { 90 | if entry.Caller.Defined { 91 | fields = appendIfNotExists(fields, SourceLocationForCaller(entry.Caller)) 92 | } 93 | return c.nextCore.Write(entry, fields) 94 | } 95 | 96 | type errorReportingCore struct { 97 | nextCore zapcore.Core 98 | serviceName string 99 | serviceVersion string 100 | } 101 | 102 | func (c errorReportingCore) Enabled(level zapcore.Level) bool { 103 | return c.nextCore.Enabled(level) 104 | } 105 | 106 | func (c errorReportingCore) With(fields []zapcore.Field) zapcore.Core { 107 | return errorReportingCore{ 108 | nextCore: c.nextCore.With(fields), 109 | serviceName: c.serviceName, 110 | serviceVersion: c.serviceVersion, 111 | } 112 | } 113 | 114 | func (c errorReportingCore) Sync() error { 115 | return c.nextCore.Sync() 116 | } 117 | 118 | // Check implements zapcore.Core. 119 | func (c errorReportingCore) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry { 120 | if !c.nextCore.Enabled(entry.Level) { 121 | return checked 122 | } 123 | return checked.AddCore(entry, c) 124 | } 125 | 126 | // Write implements zapcore.Core. 127 | func (c errorReportingCore) Write(entry zapcore.Entry, fields []zapcore.Field) error { 128 | if entry.Caller.Defined && zap.ErrorLevel.Enabled(entry.Level) { 129 | fields = appendIfNotExists(fields, ErrorReportContextForCaller(entry.Caller)) 130 | fields = appendIfNotExists(fields, ErrorReportServiceContext(c.serviceName, c.serviceVersion)) 131 | } 132 | return c.nextCore.Write(entry, fields) 133 | } 134 | 135 | func appendIfNotExists(fields []zapcore.Field, field zap.Field) []zapcore.Field { 136 | for _, existing := range fields { 137 | if existing.Key == field.Key { 138 | return fields 139 | } 140 | } 141 | return append(fields, field) 142 | } 143 | -------------------------------------------------------------------------------- /cloudzap/middleware.go: -------------------------------------------------------------------------------- 1 | package cloudzap 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "go.einride.tech/cloudrunner/cloudstream" 8 | "go.uber.org/zap" 9 | "google.golang.org/grpc" 10 | ) 11 | 12 | type Middleware struct { 13 | Logger *zap.Logger 14 | } 15 | 16 | // HTTPServer implements HTTP server middleware to add a logger to the request context. 17 | func (l *Middleware) HTTPServer(next http.Handler) http.Handler { 18 | return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 19 | next.ServeHTTP(writer, request.WithContext(WithLogger(request.Context(), l.Logger))) 20 | }) 21 | } 22 | 23 | // GRPCUnaryServerInterceptor implements grpc.UnaryServerInterceptor to add a logger to the request context. 24 | func (l *Middleware) GRPCUnaryServerInterceptor( 25 | ctx context.Context, 26 | request interface{}, 27 | _ *grpc.UnaryServerInfo, 28 | handler grpc.UnaryHandler, 29 | ) (interface{}, error) { 30 | return handler(WithLogger(ctx, l.Logger), request) 31 | } 32 | 33 | // GRPCStreamServerInterceptor adds a zap logger to the server stream context. 34 | func (l *Middleware) GRPCStreamServerInterceptor( 35 | srv interface{}, 36 | ss grpc.ServerStream, 37 | _ *grpc.StreamServerInfo, 38 | handler grpc.StreamHandler, 39 | ) (err error) { 40 | return handler(srv, cloudstream.NewContextualServerStream(WithLogger(ss.Context(), l.Logger), ss)) 41 | } 42 | -------------------------------------------------------------------------------- /cloudzap/proto.go: -------------------------------------------------------------------------------- 1 | package cloudzap 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "go.uber.org/zap" 7 | "google.golang.org/protobuf/encoding/protojson" 8 | "google.golang.org/protobuf/proto" 9 | ) 10 | 11 | // ProtoMessage constructs a zap.Field with the given key and proto message encoded as JSON. 12 | func ProtoMessage(key string, message proto.Message) zap.Field { 13 | return zap.Reflect(key, reflectProtoMessage{message: message}) 14 | } 15 | 16 | type reflectProtoMessage struct { 17 | message proto.Message 18 | } 19 | 20 | var _ json.Marshaler = reflectProtoMessage{} 21 | 22 | func (p reflectProtoMessage) MarshalJSON() ([]byte, error) { 23 | return protojson.Marshal(p.message) 24 | } 25 | -------------------------------------------------------------------------------- /cloudzap/proto_test.go: -------------------------------------------------------------------------------- 1 | package cloudzap 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "go.uber.org/zap" 8 | "go.uber.org/zap/zapcore" 9 | "go.uber.org/zap/zaptest" 10 | "google.golang.org/genproto/googleapis/example/library/v1" 11 | "gotest.tools/v3/assert" 12 | ) 13 | 14 | func TestProtoMessage(t *testing.T) { 15 | var buffer zaptest.Buffer 16 | encoder := zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()) 17 | logger := zap.New(zapcore.NewCore(encoder, &buffer, zap.DebugLevel)) 18 | logger.Info("test", ProtoMessage("protoMessage", &library.Book{ 19 | Name: "name", 20 | Author: "author", 21 | Title: "title", 22 | Read: true, 23 | })) 24 | assert.Assert( 25 | t, 26 | strings.Contains( 27 | buffer.Stripped(), 28 | `"protoMessage":{"name":"name","author":"author","title":"title","read":true}`, 29 | ), 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /cloudzap/resource.go: -------------------------------------------------------------------------------- 1 | package cloudzap 2 | 3 | import ( 4 | "go.opentelemetry.io/otel/sdk/resource" 5 | "go.uber.org/zap" 6 | "go.uber.org/zap/zapcore" 7 | ) 8 | 9 | // Resource constructs a zap.Field with the given key and OpenTelemetry resource. 10 | func Resource(key string, r *resource.Resource) zap.Field { 11 | return zap.Object(key, resourceObjectMarshaler{resource: r}) 12 | } 13 | 14 | type resourceObjectMarshaler struct { 15 | resource *resource.Resource 16 | } 17 | 18 | // MarshalLogObject implements zapcore.ObjectMarshaler. 19 | func (r resourceObjectMarshaler) MarshalLogObject(encoder zapcore.ObjectEncoder) error { 20 | it := r.resource.Iter() 21 | for it.Next() { 22 | attr := it.Attribute() 23 | if err := encoder.AddReflected(string(attr.Key), attr.Value.AsInterface()); err != nil { 24 | return err 25 | } 26 | } 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /cloudzap/resource_test.go: -------------------------------------------------------------------------------- 1 | package cloudzap 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | 8 | "go.opentelemetry.io/otel/attribute" 9 | "go.opentelemetry.io/otel/sdk/resource" 10 | "go.uber.org/zap" 11 | "go.uber.org/zap/zapcore" 12 | "go.uber.org/zap/zaptest" 13 | "gotest.tools/v3/assert" 14 | ) 15 | 16 | func TestResource(t *testing.T) { 17 | var buffer zaptest.Buffer 18 | encoder := zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()) 19 | input, err := resource.New(context.Background(), resource.WithAttributes( 20 | attribute.KeyValue{ 21 | Key: "foo", 22 | Value: attribute.StringValue("bar"), 23 | }, 24 | )) 25 | assert.NilError(t, err) 26 | logger := zap.New(zapcore.NewCore(encoder, &buffer, zap.DebugLevel)) 27 | logger.Info("test", Resource("resource", input)) 28 | assert.Assert( 29 | t, 30 | strings.Contains( 31 | buffer.Stripped(), 32 | `"resource":{"foo":"bar"}`, 33 | ), 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /cloudzap/sourcelocation.go: -------------------------------------------------------------------------------- 1 | package cloudzap 2 | 3 | import ( 4 | "runtime" 5 | "strconv" 6 | 7 | "go.uber.org/zap" 8 | "go.uber.org/zap/zapcore" 9 | ) 10 | 11 | const sourceLocationKey = "logging.googleapis.com/sourceLocation" 12 | 13 | // SourceLocationForCaller returns a structured logging field for the source location of the provided caller. 14 | func SourceLocationForCaller(caller zapcore.EntryCaller) zapcore.Field { 15 | return SourceLocation(caller.PC, caller.File, caller.Line, caller.Defined) 16 | } 17 | 18 | // SourceLocation returns a structured logging field for the provided source location. 19 | func SourceLocation(pc uintptr, file string, line int, ok bool) zapcore.Field { 20 | if !ok { 21 | return zap.Skip() 22 | } 23 | return zap.Object(sourceLocationKey, sourceLocation{ 24 | file: file, 25 | line: line, 26 | function: runtime.FuncForPC(pc).Name(), 27 | }) 28 | } 29 | 30 | type sourceLocation struct { 31 | file string 32 | line int 33 | function string 34 | } 35 | 36 | func (s sourceLocation) MarshalLogObject(encoder zapcore.ObjectEncoder) error { 37 | encoder.AddString("file", s.file) 38 | encoder.AddString("line", strconv.Itoa(s.line)) 39 | encoder.AddString("function", s.function) 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /cloudzap/trace.go: -------------------------------------------------------------------------------- 1 | package cloudzap 2 | 3 | import ( 4 | "fmt" 5 | 6 | "go.uber.org/zap" 7 | ) 8 | 9 | const ( 10 | traceKey = "logging.googleapis.com/trace" 11 | spanIDKey = "logging.googleapis.com/spanId" 12 | traceSampledKey = "logging.googleapis.com/trace_sampled" 13 | ) 14 | 15 | func Trace(projectID, traceID string) zap.Field { 16 | return zap.String(traceKey, fmt.Sprintf("projects/%s/traces/%s", projectID, traceID)) 17 | } 18 | 19 | func SpanID(spanID string) zap.Field { 20 | return zap.String(spanIDKey, spanID) 21 | } 22 | 23 | func TraceSampled(sampled bool) zap.Field { 24 | return zap.Bool(traceSampledKey, sampled) 25 | } 26 | -------------------------------------------------------------------------------- /cloudzap/trace_test.go: -------------------------------------------------------------------------------- 1 | package cloudzap 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "go.uber.org/zap" 8 | "go.uber.org/zap/zapcore" 9 | "go.uber.org/zap/zaptest" 10 | "gotest.tools/v3/assert" 11 | ) 12 | 13 | func TestTrace(t *testing.T) { 14 | var buffer zaptest.Buffer 15 | encoder := zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()) 16 | logger := zap.New(zapcore.NewCore(encoder, &buffer, zap.DebugLevel)) 17 | logger.Info("test", Trace("foo", "bar")) 18 | assert.Assert( 19 | t, 20 | strings.Contains( 21 | buffer.Stripped(), 22 | `"logging.googleapis.com/trace":"projects/foo/traces/bar"`, 23 | ), 24 | ) 25 | } 26 | 27 | func TestSpanID(t *testing.T) { 28 | var buffer zaptest.Buffer 29 | encoder := zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()) 30 | logger := zap.New(zapcore.NewCore(encoder, &buffer, zap.DebugLevel)) 31 | logger.Info("test", SpanID("foo")) 32 | assert.Assert( 33 | t, 34 | strings.Contains( 35 | buffer.Stripped(), 36 | `"logging.googleapis.com/spanId":"foo"`, 37 | ), 38 | ) 39 | } 40 | 41 | func TestTraceSampled(t *testing.T) { 42 | var buffer zaptest.Buffer 43 | encoder := zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()) 44 | logger := zap.New(zapcore.NewCore(encoder, &buffer, zap.DebugLevel)) 45 | logger.Info("test", TraceSampled(true)) 46 | assert.Assert( 47 | t, 48 | strings.Contains( 49 | buffer.Stripped(), 50 | `"logging.googleapis.com/trace_sampled":true`, 51 | ), 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /dialservice.go: -------------------------------------------------------------------------------- 1 | package cloudrunner 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "go.einride.tech/cloudrunner/cloudclient" 8 | "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" 9 | "google.golang.org/grpc" 10 | ) 11 | 12 | // DialService dials another service using the default service account's Google ID Token authentication. 13 | func DialService(ctx context.Context, target string, opts ...grpc.DialOption) (*grpc.ClientConn, error) { 14 | run, ok := getRunContext(ctx) 15 | if !ok { 16 | return nil, fmt.Errorf("cloudrunner.DialService %s: must be called with a context from cloudrunner.Run", target) 17 | } 18 | return cloudclient.DialService( 19 | ctx, 20 | target, 21 | append( 22 | []grpc.DialOption{ 23 | grpc.WithStatsHandler(otelgrpc.NewClientHandler()), 24 | grpc.WithDefaultServiceConfig(run.config.Client.AsServiceConfigJSON()), 25 | grpc.WithChainUnaryInterceptor( 26 | run.requestLoggerMiddleware.GRPCUnaryClientInterceptor, 27 | run.clientMiddleware.GRPCUnaryClientInterceptor, 28 | ), 29 | }, 30 | opts..., 31 | )..., 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package cloudrunner provides primitives for getting up and running with Go on Google Cloud. 2 | package cloudrunner 3 | -------------------------------------------------------------------------------- /examples/cmd/additional-config/example.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: serving.knative.dev/v1 2 | kind: Service 3 | metadata: 4 | name: additional-config 5 | annotations: 6 | autoscaling.knative.dev/maxScale: "100" 7 | spec: 8 | template: 9 | spec: 10 | containerConcurrency: 50 11 | containers: 12 | - image: gcr.io/cloudrun/hello 13 | resources: 14 | limits: 15 | cpu: "1" 16 | memory: 512Mi 17 | env: 18 | - name: FOO 19 | value: baz 20 | -------------------------------------------------------------------------------- /examples/cmd/additional-config/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | 7 | "go.einride.tech/cloudrunner" 8 | "google.golang.org/grpc/health" 9 | "google.golang.org/grpc/health/grpc_health_v1" 10 | ) 11 | 12 | func main() { 13 | var config struct { 14 | Foo string `default:"bar" onGCE:"baz"` 15 | MySecret string `default:"42" secret:"true"` 16 | } 17 | if err := cloudrunner.Run( 18 | func(ctx context.Context) error { 19 | grpcServer := cloudrunner.NewGRPCServer(ctx) 20 | healthServer := health.NewServer() 21 | grpc_health_v1.RegisterHealthServer(grpcServer, healthServer) 22 | return cloudrunner.ListenGRPC(ctx, grpcServer) 23 | }, 24 | cloudrunner.WithConfig("example", &config), 25 | ); err != nil { 26 | log.Fatal(err) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/cmd/grpc-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "log/slog" 7 | 8 | "go.einride.tech/cloudrunner" 9 | "google.golang.org/grpc/health" 10 | "google.golang.org/grpc/health/grpc_health_v1" 11 | ) 12 | 13 | func main() { 14 | if err := cloudrunner.Run(func(ctx context.Context) error { 15 | slog.InfoContext(ctx, "hello world") 16 | grpcServer := cloudrunner.NewGRPCServer(ctx) 17 | healthServer := health.NewServer() 18 | grpc_health_v1.RegisterHealthServer(grpcServer, healthServer) 19 | return cloudrunner.ListenGRPC(ctx, grpcServer) 20 | }); err != nil { 21 | log.Fatal(err) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/cmd/http-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | "os" 9 | 10 | "go.einride.tech/cloudrunner" 11 | ) 12 | 13 | func main() { 14 | if err := cloudrunner.Run(func(ctx context.Context) error { 15 | slog.InfoContext(ctx, "hello world") 16 | httpServer := cloudrunner.NewHTTPServer(ctx, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | slog.InfoContext(ctx, "hello from handler") 18 | cloudrunner.AddRequestLogFields(r.Context(), "foo", "bar") 19 | w.Header().Set("Content-Type", "text/plain") 20 | _, _ = w.Write([]byte("hello world")) 21 | })) 22 | return cloudrunner.ListenHTTP(ctx, httpServer) 23 | }); err != nil { 24 | fmt.Fprintln(os.Stderr, err) 25 | os.Exit(1) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.einride.tech/cloudrunner 2 | 3 | go 1.23.8 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | cloud.google.com/go/compute/metadata v0.7.0 9 | cloud.google.com/go/logging v1.13.0 10 | cloud.google.com/go/profiler v0.4.2 11 | cloud.google.com/go/pubsub v1.49.0 12 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.52.0 13 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.28.0 14 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/propagator v0.52.0 15 | github.com/google/go-cmp v0.7.0 16 | github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 17 | github.com/soheilhy/cmux v0.1.5 18 | go.einride.tech/protobuf-sensitive v0.8.0 19 | go.opentelemetry.io/contrib/detectors/gcp v1.36.0 20 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 21 | go.opentelemetry.io/contrib/instrumentation/host v0.61.0 22 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 23 | go.opentelemetry.io/contrib/instrumentation/runtime v0.61.0 24 | go.opentelemetry.io/otel v1.36.0 25 | go.opentelemetry.io/otel/bridge/opencensus v1.36.0 26 | go.opentelemetry.io/otel/sdk v1.36.0 27 | go.opentelemetry.io/otel/sdk/metric v1.36.0 28 | go.opentelemetry.io/otel/trace v1.36.0 29 | go.uber.org/zap v1.27.0 30 | golang.org/x/net v0.40.0 31 | golang.org/x/oauth2 v0.30.0 32 | golang.org/x/sync v0.14.0 33 | google.golang.org/api v0.235.0 34 | google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 35 | google.golang.org/grpc v1.72.2 36 | google.golang.org/grpc/examples v0.0.0-20240927220217-941102b7811f 37 | google.golang.org/protobuf v1.36.6 38 | gopkg.in/yaml.v3 v3.0.1 39 | gotest.tools/v3 v3.5.2 40 | ) 41 | 42 | require ( 43 | cloud.google.com/go v0.120.0 // indirect 44 | cloud.google.com/go/auth v0.16.1 // indirect 45 | cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 46 | cloud.google.com/go/iam v1.5.2 // indirect 47 | cloud.google.com/go/longrunning v0.6.7 // indirect 48 | cloud.google.com/go/monitoring v1.24.2 // indirect 49 | cloud.google.com/go/trace v1.11.6 // indirect 50 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect 51 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.52.0 // indirect 52 | github.com/ebitengine/purego v0.8.3 // indirect 53 | github.com/felixge/httpsnoop v1.0.4 // indirect 54 | github.com/go-logr/logr v1.4.3 // indirect 55 | github.com/go-logr/stdr v1.2.2 // indirect 56 | github.com/go-ole/go-ole v1.3.0 // indirect 57 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 58 | github.com/golang/protobuf v1.5.4 // indirect 59 | github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect 60 | github.com/google/s2a-go v0.1.9 // indirect 61 | github.com/google/uuid v1.6.0 // indirect 62 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect 63 | github.com/googleapis/gax-go/v2 v2.14.2 // indirect 64 | github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect 65 | github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect 66 | github.com/shirou/gopsutil/v4 v4.25.4 // indirect 67 | github.com/tklauser/go-sysconf v0.3.15 // indirect 68 | github.com/tklauser/numcpus v0.10.0 // indirect 69 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 70 | go.opencensus.io v0.24.0 // indirect 71 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 72 | go.opentelemetry.io/otel/metric v1.36.0 // indirect 73 | go.uber.org/multierr v1.11.0 // indirect 74 | golang.org/x/crypto v0.38.0 // indirect 75 | golang.org/x/sys v0.33.0 // indirect 76 | golang.org/x/text v0.25.0 // indirect 77 | golang.org/x/time v0.11.0 // indirect 78 | google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect 79 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect 80 | ) 81 | 82 | retract ( 83 | v0.77.0 // request logging bug 84 | v0.75.0 // slog migration bug 85 | ) 86 | -------------------------------------------------------------------------------- /grpcserver.go: -------------------------------------------------------------------------------- 1 | package cloudrunner 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "net" 8 | "time" 9 | 10 | "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" 11 | "google.golang.org/grpc" 12 | "google.golang.org/grpc/keepalive" 13 | ) 14 | 15 | // NewGRPCServer creates a new gRPC server preconfigured with middleware for request logging, tracing, etc. 16 | func NewGRPCServer(ctx context.Context, opts ...grpc.ServerOption) *grpc.Server { 17 | run, ok := getRunContext(ctx) 18 | if !ok { 19 | panic("cloudrunner.NewGRPCServer: must be called with a context from cloudrunner.Run") 20 | } 21 | unaryTracing := run.otelTraceMiddleware.GRPCServerUnaryInterceptor 22 | streamTracing := run.otelTraceMiddleware.GRPCStreamServerInterceptor 23 | if run.useLegacyTracing { 24 | unaryTracing = run.traceMiddleware.GRPCServerUnaryInterceptor 25 | streamTracing = run.traceMiddleware.GRPCStreamServerInterceptor 26 | } 27 | serverOptions := []grpc.ServerOption{ 28 | grpc.StatsHandler(otelgrpc.NewServerHandler()), 29 | grpc.ChainUnaryInterceptor( 30 | run.loggerMiddleware.GRPCUnaryServerInterceptor, // adds context logger 31 | unaryTracing, // needs the context logger 32 | run.requestLoggerMiddleware.GRPCUnaryServerInterceptor, // needs to run after trace 33 | run.serverMiddleware.GRPCUnaryServerInterceptor, // needs to run after request logger 34 | ), 35 | grpc.ChainStreamInterceptor( 36 | run.loggerMiddleware.GRPCStreamServerInterceptor, 37 | streamTracing, 38 | run.requestLoggerMiddleware.GRPCStreamServerInterceptor, 39 | run.serverMiddleware.GRPCStreamServerInterceptor, 40 | ), 41 | // For details on keepalive settings, see: 42 | // https://github.com/grpc/grpc-go/blob/master/Documentation/keepalive.md 43 | grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{ 44 | // If a client pings more than once every 30 seconds, terminate the connection 45 | MinTime: 30 * time.Second, 46 | // Allow pings even when there are no active streams 47 | PermitWithoutStream: true, 48 | }), 49 | } 50 | serverOptions = append(serverOptions, run.grpcServerOptions...) 51 | serverOptions = append(serverOptions, opts...) 52 | return grpc.NewServer(serverOptions...) 53 | } 54 | 55 | // ListenGRPC binds a listener on the configured port and listens for gRPC requests. 56 | func ListenGRPC(ctx context.Context, grpcServer *grpc.Server) error { 57 | run, ok := getRunContext(ctx) 58 | if !ok { 59 | return fmt.Errorf("cloudrunner.ListenGRPC: must be called with a context from cloudrunner.Run") 60 | } 61 | address := fmt.Sprintf(":%d", run.config.Runtime.Port) 62 | listener, err := (&net.ListenConfig{}).Listen( 63 | ctx, 64 | "tcp", 65 | address, 66 | ) 67 | if err != nil { 68 | return err 69 | } 70 | go func() { 71 | <-ctx.Done() 72 | slog.InfoContext(ctx, "gRPCServer shutting down") 73 | grpcServer.GracefulStop() 74 | }() 75 | slog.InfoContext(ctx, "gRPCServer listening", slog.String("address", address)) 76 | return grpcServer.Serve(listener) 77 | } 78 | -------------------------------------------------------------------------------- /httpserver.go: -------------------------------------------------------------------------------- 1 | package cloudrunner 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "net/http" 9 | "time" 10 | 11 | "go.einride.tech/cloudrunner/cloudserver" 12 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 13 | ) 14 | 15 | // HTTPMiddleware is an HTTP middleware. 16 | type HTTPMiddleware = func(http.Handler) http.Handler 17 | 18 | // NewHTTPServer creates a new HTTP server preconfigured with middleware for request logging, tracing, etc. 19 | func NewHTTPServer(ctx context.Context, handler http.Handler, middlewares ...HTTPMiddleware) *http.Server { 20 | if handler == nil { 21 | panic("cloudrunner.NewHTTPServer: handler must not be nil") 22 | } 23 | run, ok := getRunContext(ctx) 24 | if !ok { 25 | panic("cloudrunner.NewHTTPServer: must be called with a context from cloudrunner.Run") 26 | } 27 | tracingMiddleware := run.otelTraceMiddleware.HTTPServer 28 | if run.useLegacyTracing { 29 | tracingMiddleware = run.traceMiddleware.HTTPServer 30 | } 31 | defaultMiddlewares := []cloudserver.HTTPMiddleware{ 32 | func(handler http.Handler) http.Handler { 33 | return otelhttp.NewHandler(handler, "server") 34 | }, 35 | run.loggerMiddleware.HTTPServer, 36 | tracingMiddleware, 37 | run.requestLoggerMiddleware.HTTPServer, 38 | run.securityHeadersMiddleware.HTTPServer, 39 | run.serverMiddleware.HTTPServer, 40 | } 41 | return &http.Server{ 42 | Addr: fmt.Sprintf(":%d", run.config.Runtime.Port), 43 | Handler: cloudserver.ChainHTTPMiddleware( 44 | handler, 45 | append(defaultMiddlewares, middlewares...)..., 46 | ), 47 | ReadTimeout: run.serverMiddleware.Config.Timeout, 48 | ReadHeaderTimeout: run.serverMiddleware.Config.Timeout, 49 | WriteTimeout: run.serverMiddleware.Config.Timeout, 50 | IdleTimeout: run.serverMiddleware.Config.Timeout, 51 | } 52 | } 53 | 54 | // ListenHTTP binds a listener on the configured port and listens for HTTP requests. 55 | func ListenHTTP(ctx context.Context, httpServer *http.Server) error { 56 | go func() { 57 | <-ctx.Done() 58 | slog.InfoContext(ctx, "HTTPServer shutting down") 59 | 60 | shutdownContext, cancel := context.WithTimeout(context.Background(), 30*time.Second) 61 | defer cancel() 62 | 63 | httpServer.SetKeepAlivesEnabled(false) 64 | if err := httpServer.Shutdown(shutdownContext); err != nil { 65 | slog.ErrorContext(ctx, "HTTPServer shutdown error", slog.Any("error", err)) 66 | } 67 | }() 68 | slog.InfoContext(ctx, "HTTPServer listening", slog.String("address", httpServer.Addr)) 69 | if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { 70 | return err 71 | } 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package cloudrunner 2 | 3 | import ( 4 | "context" 5 | 6 | "go.einride.tech/cloudrunner/cloudrequestlog" 7 | "go.einride.tech/cloudrunner/cloudzap" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | // Logger returns the logger for the current context. 12 | func Logger(ctx context.Context) *zap.Logger { 13 | logger, ok := cloudzap.GetLogger(ctx) 14 | if !ok { 15 | panic("cloudrunner.Logger must be called with a context from cloudrunner.Run") 16 | } 17 | return logger 18 | } 19 | 20 | // WithLoggerFields attaches structured fields to a new logger in the returned child context. 21 | func WithLoggerFields(ctx context.Context, fields ...zap.Field) context.Context { 22 | logger, ok := cloudzap.GetLogger(ctx) 23 | if !ok { 24 | panic("cloudrunner.WithLoggerFields must be called with a context from cloudrunner.Run") 25 | } 26 | return cloudzap.WithLogger(ctx, logger.With(fields...)) 27 | } 28 | 29 | // AddRequestLogFields adds fields to the current request log, and is safe to call concurrently. 30 | func AddRequestLogFields(ctx context.Context, args ...any) { 31 | requestLogFields, ok := cloudrequestlog.GetAdditionalFields(ctx) 32 | if !ok { 33 | panic("cloudrunner.AddRequestLogFields must be called with a context from cloudrequestlog.Middleware") 34 | } 35 | requestLogFields.Add(args...) 36 | } 37 | 38 | // AddRequestLogFieldsToArray appends objects to an array field in the request log and is safe to call concurrently. 39 | func AddRequestLogFieldsToArray(ctx context.Context, key string, objects ...any) { 40 | additionalFields, ok := cloudrequestlog.GetAdditionalFields(ctx) 41 | if !ok { 42 | panic("cloudrunner.AddRequestLogFieldsToArray must be called with a context from cloudrequestlog.Middleware") 43 | } 44 | additionalFields.AddToArray(key, objects...) 45 | } 46 | -------------------------------------------------------------------------------- /muxserver.go: -------------------------------------------------------------------------------- 1 | package cloudrunner 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | 9 | "go.einride.tech/cloudrunner/cloudmux" 10 | "google.golang.org/grpc" 11 | ) 12 | 13 | // ListenGRPCHTTP binds a listener on the configured port and listens for gRPC and HTTP requests. 14 | func ListenGRPCHTTP(ctx context.Context, grpcServer *grpc.Server, httpServer *http.Server) error { 15 | l, err := (&net.ListenConfig{}).Listen(ctx, "tcp", fmt.Sprintf(":%d", Runtime(ctx).Port)) 16 | if err != nil { 17 | return fmt.Errorf("serve gRPC and HTTP: %w", err) 18 | } 19 | if err := cloudmux.ServeGRPCHTTP(ctx, l, grpcServer, httpServer); err != nil { 20 | return fmt.Errorf("serve gRPC and HTTP: %w", err) 21 | } 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package cloudrunner 2 | 3 | import ( 4 | "context" 5 | 6 | "go.einride.tech/cloudrunner/cloudconfig" 7 | "go.einride.tech/cloudrunner/cloudotel" 8 | "go.einride.tech/cloudrunner/cloudtrace" 9 | "google.golang.org/grpc" 10 | "google.golang.org/protobuf/proto" 11 | ) 12 | 13 | // Option provides optional configuration for a run context. 14 | type Option func(*runContext) 15 | 16 | // WithRequestLoggerMessageTransformer configures the request logger with a message transformer. 17 | // Deprecated: This was historically used for redaction. All proto messages are now automatically redacted. 18 | func WithRequestLoggerMessageTransformer(func(proto.Message) proto.Message) Option { 19 | return func(*runContext) {} 20 | } 21 | 22 | // WithConfig configures an additional config struct to be loaded. 23 | func WithConfig(name string, config interface{}) Option { 24 | return func(run *runContext) { 25 | run.configOptions = append(run.configOptions, cloudconfig.WithAdditionalSpec(name, config)) 26 | } 27 | } 28 | 29 | // WithOptions configures the run context with a list of options. 30 | func WithOptions(options []Option) Option { 31 | return func(run *runContext) { 32 | for _, option := range options { 33 | option(run) 34 | } 35 | } 36 | } 37 | 38 | // WithGRPCServerOptions configures the run context with additional default options for NewGRPCServer. 39 | func WithGRPCServerOptions(grpcServerOptions ...grpc.ServerOption) Option { 40 | return func(run *runContext) { 41 | run.grpcServerOptions = append(run.grpcServerOptions, grpcServerOptions...) 42 | } 43 | } 44 | 45 | // WithTraceHook configures the run context with a trace hook. 46 | // Deprecated: use WithOtelTraceHook instead. 47 | func WithTraceHook(traceHook func(context.Context, cloudtrace.Context) context.Context) Option { 48 | return func(run *runContext) { 49 | run.useLegacyTracing = true 50 | run.traceMiddleware.TraceHook = traceHook 51 | } 52 | } 53 | 54 | // WithTraceHook configures the run context with a trace hook. 55 | func WithOtelTraceHook(traceHook cloudotel.TraceHook) Option { 56 | return func(run *runContext) { 57 | run.otelTraceMiddleware.TraceHook = traceHook 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pubsubhttphandler.go: -------------------------------------------------------------------------------- 1 | package cloudrunner 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "cloud.google.com/go/pubsub/apiv1/pubsubpb" 8 | "go.einride.tech/cloudrunner/cloudpubsub" 9 | ) 10 | 11 | // PubsubHTTPHandler creates a new HTTP handler for Cloud Pub/Sub push messages. 12 | // See: https://cloud.google.com/pubsub/docs/push 13 | func PubsubHTTPHandler(fn func(context.Context, *pubsubpb.PubsubMessage) error) http.Handler { 14 | return cloudpubsub.HTTPHandler(fn) 15 | } 16 | -------------------------------------------------------------------------------- /run_test.go: -------------------------------------------------------------------------------- 1 | package cloudrunner_test 2 | 3 | import ( 4 | "context" 5 | "log" 6 | 7 | "go.einride.tech/cloudrunner" 8 | "google.golang.org/grpc/health" 9 | "google.golang.org/grpc/health/grpc_health_v1" 10 | ) 11 | 12 | func ExampleRun_helloWorld() { 13 | if err := cloudrunner.Run(func(ctx context.Context) error { 14 | cloudrunner.Logger(ctx).Info("hello world") 15 | return nil 16 | }); err != nil { 17 | log.Fatal(err) 18 | } 19 | } 20 | 21 | func ExampleRun_gRPCServer() { 22 | if err := cloudrunner.Run(func(ctx context.Context) error { 23 | grpcServer := cloudrunner.NewGRPCServer(ctx) 24 | healthServer := health.NewServer() 25 | grpc_health_v1.RegisterHealthServer(grpcServer, healthServer) 26 | return cloudrunner.ListenGRPC(ctx, grpcServer) 27 | }); err != nil { 28 | log.Fatal(err) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /runtime.go: -------------------------------------------------------------------------------- 1 | package cloudrunner 2 | 3 | import ( 4 | "context" 5 | 6 | "go.einride.tech/cloudrunner/cloudruntime" 7 | ) 8 | 9 | // Runtime returns the runtime config for the current context. 10 | func Runtime(ctx context.Context) cloudruntime.Config { 11 | config, ok := cloudruntime.GetConfig(ctx) 12 | if !ok { 13 | panic("cloudrunner.Runtime must be called with a context from cloudrunner.Run") 14 | } 15 | return config 16 | } 17 | -------------------------------------------------------------------------------- /trace.go: -------------------------------------------------------------------------------- 1 | package cloudrunner 2 | 3 | import ( 4 | "context" 5 | 6 | cloudtrace "go.einride.tech/cloudrunner/cloudtrace" 7 | ) 8 | 9 | // IncomingTraceContext returns the Cloud Trace context from the incoming request metadata. 10 | // Deprecated: Use opentelemetry trace.SpanContextFromContext instead. 11 | func IncomingTraceContext(ctx context.Context) (cloudtrace.Context, bool) { 12 | return cloudtrace.FromIncomingContext(ctx) 13 | } 14 | 15 | // GetTraceContext returns the Cloud Trace context from the incoming request. 16 | // Deprecated: Use opentelemetry trace.SpanContextFromContext instead. 17 | func GetTraceContext(ctx context.Context) (cloudtrace.Context, bool) { 18 | return cloudtrace.GetContext(ctx) 19 | } 20 | -------------------------------------------------------------------------------- /usage.go: -------------------------------------------------------------------------------- 1 | package cloudrunner 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path" 9 | "text/tabwriter" 10 | 11 | "go.einride.tech/cloudrunner/cloudconfig" 12 | "go.einride.tech/cloudrunner/cloudruntime" 13 | ) 14 | 15 | func printUsage(w io.Writer, config *cloudconfig.Config) { 16 | _, _ = fmt.Fprintf(w, "\nUsage of %s:\n\n", path.Base(os.Args[0])) 17 | flag.CommandLine.PrintDefaults() 18 | _, _ = fmt.Fprintf(w, "\nRuntime configuration of %s:\n\n", path.Base(os.Args[0])) 19 | config.PrintUsage(w) 20 | _, _ = fmt.Fprintf(w, "\nBuild-time configuration of %s:\n\n", path.Base(os.Args[0])) 21 | tabs := tabwriter.NewWriter(w, 1, 0, 4, ' ', 0) 22 | _, _ = fmt.Fprintf(tabs, "LDFLAG\tTYPE\tVALUE\n") 23 | _, _ = fmt.Fprintf( 24 | tabs, 25 | "%v\t%v\t%v\n", 26 | "go.einride.tech/cloudrunner/cloudruntime.serviceVersion", 27 | "string", 28 | cloudruntime.ServiceVersionFromLinkerFlags(), 29 | ) 30 | _ = tabs.Flush() 31 | } 32 | -------------------------------------------------------------------------------- /wrap.go: -------------------------------------------------------------------------------- 1 | package cloudrunner 2 | 3 | import ( 4 | "runtime" 5 | 6 | "go.einride.tech/cloudrunner/clouderror" 7 | "google.golang.org/grpc/status" 8 | ) 9 | 10 | // Wrap masks the gRPC status of the provided error by replacing it with the provided status. 11 | func Wrap(err error, s *status.Status) error { 12 | return clouderror.WrapCaller(err, s, clouderror.NewCaller(runtime.Caller(1))) 13 | } 14 | 15 | // WrapTransient masks the gRPC status of the provided error by replacing the status message. 16 | // If the original error has transient (retryable) gRPC status code, the status code will be forwarded. 17 | // Otherwise, the status code will be masked with INTERNAL. 18 | func WrapTransient(err error, msg string) error { 19 | return clouderror.WrapTransientCaller(err, msg, clouderror.NewCaller(runtime.Caller(1))) 20 | } 21 | -------------------------------------------------------------------------------- /wrap_test.go: -------------------------------------------------------------------------------- 1 | package cloudrunner 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "google.golang.org/grpc/codes" 9 | "google.golang.org/grpc/status" 10 | "gotest.tools/v3/assert" 11 | ) 12 | 13 | func Test_WrapTransient(t *testing.T) { 14 | t.Parallel() 15 | for _, tt := range []struct { 16 | name string 17 | err error 18 | expectedCode codes.Code 19 | }{ 20 | { 21 | name: "nil", 22 | err: nil, 23 | expectedCode: codes.Internal, 24 | }, 25 | { 26 | name: "codes.DeadlineExceeded", 27 | err: status.Error(codes.DeadlineExceeded, "transient"), 28 | expectedCode: codes.DeadlineExceeded, 29 | }, 30 | { 31 | name: "codes.Canceled", 32 | err: status.Error(codes.Canceled, "transient"), 33 | expectedCode: codes.Canceled, 34 | }, 35 | { 36 | name: "codes.Unavailable", 37 | err: status.Error(codes.Unavailable, "transient"), 38 | expectedCode: codes.Unavailable, 39 | }, 40 | { 41 | name: "wrapped transient", 42 | err: Wrap( 43 | fmt.Errorf("network unavailable"), 44 | status.New(codes.Unavailable, "bad"), 45 | ), 46 | expectedCode: codes.Unavailable, 47 | }, 48 | { 49 | name: "context.DeadlineExceeded", 50 | err: context.DeadlineExceeded, 51 | expectedCode: codes.DeadlineExceeded, 52 | }, 53 | { 54 | name: "context.Canceled", 55 | err: context.Canceled, 56 | expectedCode: codes.Canceled, 57 | }, 58 | { 59 | name: "wrapped context.Canceled", 60 | err: fmt.Errorf("bad: %w", context.Canceled), 61 | expectedCode: codes.Canceled, 62 | }, 63 | } { 64 | t.Run(tt.name, func(t *testing.T) { 65 | t.Parallel() 66 | got := WrapTransient(tt.err, "boom") 67 | assert.Equal(t, tt.expectedCode, status.Code(got), got) 68 | }) 69 | } 70 | } 71 | --------------------------------------------------------------------------------