diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..36bae89 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.* +Dockerfile* +Makefile* +.git* +*.md +twelvedata-exporter diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f90842f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Main binary +twelvedata-exporter + +# Other files +.DS_Store +bin +dist +tmp +vendor +.envrc diff --git a/.goimportsignore b/.goimportsignore new file mode 100644 index 0000000..dee4f05 --- /dev/null +++ b/.goimportsignore @@ -0,0 +1,5 @@ +.vscode +.DS_Store +bin +dist +tmp diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..72dd9fe --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,85 @@ +# Refer to https://golangci-lint.run/usage/configuration/ + +run: + concurrency: 4 + timeout: 5m + skip-dirs: + - .git + - .vscode + - scripts + - tmp + - dist + - vendor + modules-download-mode: readonly + allow-parallel-runners: true + +output: + sort-results: true + +linters: + disable-all: true + enable: + - bodyclose + - deadcode + - dogsled + - errcheck + - exhaustive + - exportloopref + - funlen + - gochecknoinits + - goconst + - gocritic + - gocyclo + - gofmt + - gofumpt + - goimports + - golint + - goprintffuncname + - gosec + - gosimple + - govet + - ineffassign + - lll + - nakedret + - noctx + - nolintlint + - revive + - rowserrcheck + - staticcheck + - structcheck + - typecheck + - unconvert + - unparam + - unused + - varcheck + - whitespace + +# linters-settings inspired by prometheus/prometheus. +linters-settings: + depguard: + list-type: blacklist + include-go-root: true + packages: + - sync/atomic + - github.com/stretchr/testify/assert + funlen: + lines: 100 + statements: 50 + lll: + line-length: 150 + gosec: + excludes: + - G101 + gofumpt: + extra-rules: true + +# issues was inspired by uber-go/guide. +issues: + exclude-use-default: false + max-issues-per-linter: 0 + max-same-issues: 0 + # issues.exclude-rules was inspired by prometheus/prometheus. + exclude-rules: + - path: _test.go + linters: + - errcheck diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..e93e779 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,46 @@ +# .goreleaser.yml + +project_name: twelvedata-exporter + +env: + - GO111MODULE=on + +# Build destination +github_urls: + # set to true if you use a self-signed certificate + skip_tls_verify: false + +before: + hooks: + - go mod tidy + +builds: + - main: main.go + id: twelvedata-exporter + binary: twelvedata-exporter + targets: + - linux_amd64 + - linux_arm64 + - darwin_amd64 + - darwin_arm64 + - windows_amd64 + ldflags: + - -s -w + env: + - CGO_ENABLED=0 + asmflags: + - all=-trimpath=. + gcflags: + - all=-trimpath=. + +archives: + - name_template: '{{ .ProjectName }}-v{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' + format_overrides: + - goos: windows + format: zip + +release: + prerelease: auto + +checksum: + name_template: "{{ .ProjectName }}-v{{ .Version }}_checksums.txt" diff --git a/.husky.yaml b/.husky.yaml new file mode 100644 index 0000000..eeaf25a --- /dev/null +++ b/.husky.yaml @@ -0,0 +1,8 @@ +hooks: + pre-commit: + - golangci-lint run + - husky lint-staged + +lint-staged: + "*.go": + - gofmt -l -w diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..5554ede --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,10 @@ +{ + "default": true, + "line_length": false, + "no-inline-html": false, + "no-trailing-punctuation": false, + "no-duplicate-heading": false, + "no-bare-urls": false, + "header-increment": false, + "no-alt-text": false +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..193fae0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1-alpine AS builder + +ARG UID=60000 +ARG TWELVEDATA_API_KEY +ENV TWELVEDATA_API_KEY=$TWELVEDATA_API_KEY + +# Copy the repo contents into /tmp/build +WORKDIR /tmp/build +COPY . . + +RUN cd /tmp/build && \ + go mod download && \ + go build + +# Build the small image +FROM alpine +WORKDIR /app +COPY --from=builder /tmp/build/twelvedata-exporter . + +EXPOSE 9341 +USER ${UID} +ENTRYPOINT [ "./twelvedata-exporter" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f628684 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 umatare5 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c211b2b --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +.PHONY: image force-image build + +bin := twelvedata-exporter +src := $(wildcard *.go) + +# Default target +${bin}: Makefile ${src} + go build -v -o "${bin}" + +# Docker targets +image: + docker build -t ${USER}/twelvedata-exporter . + +force-image: + docker build --no-cache -t ${USER}/twelvedata-exporter . diff --git a/README.md b/README.md new file mode 100644 index 0000000..54d6d98 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# twelvedata-exporter + +![](https://github.com/umatare5/twelvedata-exporter/workflows/Go/badge.svg) + +This is a simple stock and funds quotes exporter for +[prometheus](http://prometheus.io). This exporter allows a prometheus instance +to monitor prices of stocks, ETFs, and mutual funds, possibly alerting the user +on any desirable condition (note: prometheus configuration not covered here.) + +## Data Provider Setup + +This project uses the [stonks page](https://stonks.scd31.com) to fetch stock +price information. This method **does not** support Mutual Funds, but avoids +the hassle of having to create an API key and quota issues of most financial +API providers. + +The program is smart enough to "memoize" calls to the financial data provider +and by default caches quotes for 10m. This should reduce the load on the +finance servers, as prometheus tends to scrape exporters on short time +intervals. + +## Building the exporter + +To build the exporter, you need a relatively recent version of the [Go +compiler](http://golang.org). Download and install the Go compiler and type the +following commands to download, compile, and install the twelvedata-exporter binary +to `/usr/local/bin`: + +```bash +OLDGOPATH="$GOPATH" +export GOPATH="/tmp/tempgo" +go get -u -t -v github.com/umatare5/twelvedata-exporter +sudo mv $GOPATH/bin/twelvedata-exporter /usr/local/bin +export GOPATH=$OLDGOPATH +rm -rf /tmp/tempgo +``` + +## Docker image + +The repository includes a ready to use `Dockerfile`. To build a new image, type: + +```bash +make image +``` + +Run `docker images` to see the list of images. The new image is named as +$USER/twelvedata-exporter and exports port 9341 to your host. + +## Running the exporter + +To run the exporter, just type: + +```base +twelvedata-exporter +``` + +The exporter listens on port 9341 by default. You can use the `--port` command-line +flag to change the port number, if necessary. + +## Testing + +Use your browser to access [localhost:9341](http://localhost:9341). The exporter should display a simple +help page. If that's OK, you can attempt to fetch a stock using something like: + +[http://localhost:9341/price?symbols=GOOGL](http://localhost:9341/price?symbols=GOOGL) + +The result should be similar to: + +``` +# HELP twelvedata_stock_price Asset Price. +# TYPE twelvedata_stock_price gauge +twelvedata_stock_price{name="Alphabet Inc.",symbol="GOOGL"} 1333.54 +# HELP twelvedata_exporter_failed_queries_total Count of failed queries +# TYPE twelvedata_exporter_failed_queries_total counter +twelvedata_exporter_failed_queries_total 1 +# HELP twelvedata_exporter_queries_total Count of completed queries +# TYPE twelvedata_exporter_queries_total counter +twelvedata_exporter_queries_total 5 +# HELP twelvedata_exporter_query_duration_seconds Duration of queries to the upstream API +# TYPE twelvedata_exporter_query_duration_seconds summary +twelvedata_exporter_query_duration_seconds_sum 0.000144555 +twelvedata_exporter_query_duration_seconds_count 4 +``` + +## Acknowledgements + +I started looking around for a prometheus compatible quotes exporter but +couldn't find anything that satisfied my needs. The closest I found was +[Tristan Colgate-McFarlane](https://github.com/tcolgate)'s [yquotes +exporter](https://github.com/tcolgate/ytwelvedata_exporter), which has stopped +working as Yahoo appears to have deprecated the endpoints required to download +stock data. My thanks to Tristan for his code, which served as the initial +template for this project. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/air.toml b/air.toml new file mode 100644 index 0000000..d864e48 --- /dev/null +++ b/air.toml @@ -0,0 +1,3 @@ +[build] +cmd = "go build -o twelvedata-exporter ./cmd/main.go" +bin = "twelvedata-exporter --limit 8" diff --git a/cli/config.go b/cli/config.go new file mode 100644 index 0000000..cabfd27 --- /dev/null +++ b/cli/config.go @@ -0,0 +1,71 @@ +// Package cli is responsible for the execution of the CLI. +package cli + +import ( + "errors" + "log" + + "github.com/jinzhu/configor" + "github.com/urfave/cli/v2" +) + +// Config struct +type Config struct { + WebListenAddress string + WebListenPort int + TwelvedataAPIKey string + TwelvedataRateLimit int +} + +// New returns Config struct +func newConfig(ctx *cli.Context) Config { + config := Config{ + WebListenAddress: ctx.String(webListenAddressFlagName), + WebListenPort: ctx.Int(webListenPortFlagName), + TwelvedataAPIKey: ctx.String(twelvedataAPIKeyFlagName), + TwelvedataRateLimit: ctx.Int(twelvedataRateLimitFlagName), + } + + err := configor.New(&configor.Config{}).Load(&config) + if err != nil { + log.Fatal(err) + } + + if err := isValidWebListenAddressFlag(config.WebListenAddress); err != nil { + log.Fatal(err) + } + + if err := isValidWebListenPortFlag(config.WebListenPort); err != nil { + log.Fatal(err) + } + + if err := isValidTwelvedataAPIKeyFlag(config.TwelvedataAPIKey); err != nil { + log.Fatal(err) + } + + if err := isValidTwelvedataRateLimitFlag(config.TwelvedataRateLimit); err != nil { + log.Fatal(err) + } + + return config +} + +func isValidWebListenAddressFlag(_ string) error { + return nil +} + +func isValidWebListenPortFlag(_ int) error { + return nil +} + +func isValidTwelvedataAPIKeyFlag(apikey string) error { + if apikey == "" { + return errors.New("Environment variable 'TWELVEDATA_API_KEY' is not set") + } + + return nil +} + +func isValidTwelvedataRateLimitFlag(_ int) error { + return nil +} diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..6699883 --- /dev/null +++ b/cli/main.go @@ -0,0 +1,106 @@ +// Package cli is responsible for the execution of the CLI. +package cli + +import ( + "fmt" + "os" + + server "github.com/umatare5/twelvedata-exporter/internal" + "github.com/urfave/cli/v2" +) + +const ( + webListenAddressFlagName = "web.listen-address" + webListenPortFlagName = "web.listen-port" + twelvedataAPIKeyFlagName = "twelvedata.api-key" + twelvedataRateLimitFlagName = "twelvedata.rate-limit" +) + +// Start is a entrypoint of this command +func Start() { + cmd := &cli.App{ + Name: "twelvedata-exporter", + HelpName: "Fetch metrics from Twelvedata API", + Usage: "twelvedata-exporter", + UsageText: "twelvedata-exporter COMMAND [options...]", + Version: "0.1.0", + Flags: registerFlags(), + Action: func(ctx *cli.Context) error { + config := newConfig(ctx) + server := server.New( + config.WebListenAddress, + config.WebListenPort, + config.TwelvedataAPIKey, + config.TwelvedataRateLimit, + ) + server.Boot() + + return nil + }, + } + + err := cmd.Run(os.Args) + if err != nil { + fmt.Println(err) + return + } +} + +// registerFlags returns global flags +func registerFlags() []cli.Flag { + flags := []cli.Flag{} + flags = append(flags, registerWebListenAddressFlag()...) + flags = append(flags, registerWebListenPortFlag()...) + flags = append(flags, registerAPIKeyFlag()...) + flags = append(flags, registerRateLimitFlag()...) + return flags +} + +// registerWebListenAddressFlag +func registerWebListenAddressFlag() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: webListenAddressFlagName, + Usage: "Set IP address", + Aliases: []string{"I"}, + Value: "0.0.0.0", + }, + } +} + +// registerWebListenPortFlag +func registerWebListenPortFlag() []cli.Flag { + return []cli.Flag{ + &cli.IntFlag{ + Name: webListenPortFlagName, + Usage: "Set port number", + Aliases: []string{"P"}, + Value: 9341, + }, + } +} + +// registerAPIKeyFlag +func registerAPIKeyFlag() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: twelvedataAPIKeyFlagName, + Usage: "Set key to use twelvedata API", + Aliases: []string{"a"}, + EnvVars: []string{"TWELVEDATA_API_KEY"}, + Required: true, + }, + } +} + +// registerRateLimitFlag +func registerRateLimitFlag() []cli.Flag { + return []cli.Flag{ + &cli.IntFlag{ + Name: twelvedataRateLimitFlagName, + Usage: "Set rate limit per minute to use twelvedata API", + Aliases: []string{"l"}, + Value: 0, + }, + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dd8ab2e --- /dev/null +++ b/go.mod @@ -0,0 +1,29 @@ +module github.com/umatare5/twelvedata-exporter + +go 1.20 + +require ( + github.com/jinzhu/configor v1.2.1 + github.com/kofalt/go-memoize v0.0.0-20220914132407-0b5d6a304579 + github.com/prometheus/client_golang v1.11.1 + github.com/urfave/cli/v2 v2.25.7 +) + +require ( + github.com/BurntSushi/toml v1.3.2 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.1.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/golang/protobuf v1.4.3 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/prometheus/client_model v0.2.0 // indirect + github.com/prometheus/common v0.26.0 // indirect + github.com/prometheus/procfs v0.6.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.10.0 // indirect + google.golang.org/protobuf v1.26.0-rc.1 // indirect + gopkg.in/yaml.v2 v2.3.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e8a4766 --- /dev/null +++ b/go.sum @@ -0,0 +1,164 @@ +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko= +github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kofalt/go-memoize v0.0.0-20220914132407-0b5d6a304579 h1:RbY+urZu3ri7Medi8pY3ovt1+XQxxv7zSkgmEZ5E0CU= +github.com/kofalt/go-memoize v0.0.0-20220914132407-0b5d6a304579/go.mod h1:PifxINf6wYU0USPBk0z1Z8Pka1AqeyCJAp9ecCcNL5Q= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.11.1 h1:+4eQaD7vAZ6DsfsxB15hbE0odUjGI5ARs9yskGu1v4s= +github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.26.0 h1:iMAkS2TDoNWnKM+Kopnx/8tnEStIfpYA0ur0xQzzhMQ= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= +github.com/smartystreets/gunit v1.4.2 h1:tyWYZffdPhQPfK5VsMQXfauwnJkqg7Tv5DLuQVYxq3Q= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= +github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1 h1:7QnIQpGRHE5RnLKnESfDoxm2dTapTZua5a0kS0A+VXQ= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/client.go b/internal/client.go new file mode 100644 index 0000000..b26efb7 --- /dev/null +++ b/internal/client.go @@ -0,0 +1,76 @@ +// Package internal is a server that uses the twelvedata API as its backend. +package internal + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +const ( + twelvedataQuoteURL = "https://api.twelvedata.com/quote?symbol=%s&apikey=%s" +) + +// Quote is a response from twelvedata Quote endpoint +type Quote struct { + Symbol string `json:"symbol"` + Name string `json:"name"` + Exchange string `json:"exchange"` + MicCode string `json:"mic_code"` + Currency string `json:"currency"` + Datetime string `json:"datetime"` + Timestamp int `json:"timestamp"` + Open string `json:"open"` + High string `json:"high"` + Low string `json:"low"` + Close string `json:"close"` + Volume string `json:"volume"` + PreviousClose string `json:"previous_close"` + Change string `json:"change"` + PercentChange string `json:"percent_change"` + AverageVolume string `json:"average_volume"` + IsMarketOpen bool `json:"is_market_open"` + FiftyTwoWeek fiftyTwoWeek `json:"fifty_two_week"` +} + +type fiftyTwoWeek struct { + Low string `json:"low"` + High string `json:"high"` + LowChange string `json:"low_change"` + HighChange string `json:"high_change"` + LowChangePercent string `json:"low_change_percent"` + HighChangePercent string `json:"high_change_percent"` + Range string `json:"range"` +} + +// FetchQuote returns the current value of a symbol. +func FetchQuote(symbol, apikey string) (*Quote, error) { + symbol = strings.ToUpper(symbol) + + resp, err := http.Get(fmt.Sprintf(twelvedataQuoteURL, symbol, apikey)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var data Quote + err = json.Unmarshal(body, &data) + if err != nil { + fmt.Println("Error parsing JSON:", err) + return nil, err + } + + if data.Name == "" { + fmt.Println("Name is not included in JSON:", err) + return nil, err + } + + return &data, nil +} diff --git a/internal/collector.go b/internal/collector.go new file mode 100644 index 0000000..8ddd7e6 --- /dev/null +++ b/internal/collector.go @@ -0,0 +1,181 @@ +// Package internal is a server that uses the twelvedata API as its backend. +package internal + +import ( + "fmt" + "log" + "net/url" + "strconv" + "strings" + "time" + + "github.com/kofalt/go-memoize" + "github.com/prometheus/client_golang/prometheus" +) + +var ( + // These are metrics for the collector itself + queryDuration = prometheus.NewSummary( + prometheus.SummaryOpts{ + Name: "twelvedata_query_duration_seconds", + Help: "Duration of queries to the upstream API", + }, + ) + queryCount = prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "twelvedata_queries_total", + Help: "Count of completed queries", + }, + ) + errorCount = prometheus.NewCounter( + prometheus.CounterOpts{ + Name: "twelvedata_failed_queries_total", + Help: "Count of failed queries", + }, + ) + + // Cache external API consuming calls for 10 minutes. + cache *memoize.Memoizer = memoize.NewMemoizer(10*time.Minute, 20*time.Minute) +) + +// collector holds data for a prometheus collector. +type collector struct { + apikey string + limit int + symbols []string +} + +// newCollector returns a new collector object with parsed data from the URL object. +func newCollector(myURL *url.URL, apiKey string, limit int) (collector, error) { + var symbols []string + + // The typical query is formatted as: ?symbols=AAA,BBB...&symbols=CCC,DDD... + // We fetch all symbols into a single slice. + querySymbols, exists := myURL.Query()["symbols"] + if !exists { + return collector{}, fmt.Errorf("missing symbols in the query") + } + + for _, qValue := range querySymbols { + symbols = append(symbols, strings.Split(qValue, ",")...) + } + + return collector{apiKey, limit, symbols}, nil +} + +// Describe outputs description for prometheus timeseries. +func (c *collector) Describe(ch chan<- *prometheus.Desc) { + // Must send one description, or the registry panics. + ch <- prometheus.NewDesc("dummy", "dummy", nil, nil) +} + +// Collect retrieves quote data and outputs Prometheus compatible time series on +// the output channel. +func (c *collector) Collect(ch chan<- prometheus.Metric) { + queryCount.Inc() + + for _, symbol := range c.symbols { + quote, cached := c.fetchQuoteData(symbol) + if quote == nil { + continue + } + + ls := []string{"symbol", "name", "exchange", "currency"} + lvs := c.createLabelValues(symbol, quote) + + changedPrice, _ := strconv.ParseFloat(quote.Change, 64) + changedPercent, _ := strconv.ParseFloat(quote.PercentChange, 64) + currentVolume, _ := strconv.ParseFloat(quote.Volume, 64) + previousClosePrice, _ := strconv.ParseFloat(quote.PreviousClose, 64) + currentPrice := previousClosePrice + changedPrice + + c.logRetrievedData(symbol, cached, currentPrice) + + c.sendPrometheusMetrics(ch, ls, lvs, changedPrice, changedPercent, currentVolume, currentPrice) + } +} + +// fetchQuoteData fetches quote data for a single symbol using the cachedFetcher. +func (c *collector) fetchQuoteData(symbol string) (*Quote, bool) { + cachedFetcher := func() (interface{}, error) { + res, err := FetchQuote(symbol, c.apikey) + if err != nil { + errorCount.Inc() + log.Printf("Error looking up %s: %v\n", symbol, err) + return nil, nil + } + return res, nil + } + + start := time.Now() + qret, err, cached := cache.Memoize(symbol, cachedFetcher) + queryDuration.Observe(time.Since(start).Seconds()) + + if err != nil { + errorCount.Inc() + log.Printf("Error looking up %s: %v\n", symbol, err) + return nil, false + } + + quote, ok := qret.(*Quote) + if !ok { + errorCount.Inc() + log.Printf("Invalid quote data for %s: %v\n", symbol, qret) + return nil, false + } + + return quote, cached +} + +// createLabelValues creates label values for a given symbol and its quote data. +func (c *collector) createLabelValues(symbol string, quote *Quote) []string { + return []string{symbol, quote.Name, quote.Exchange, quote.Currency} +} + +// logRetrievedData logs the retrieved data for a given symbol and its quote data. +func (c *collector) logRetrievedData(symbol string, cached bool, currentPrice float64) { + cachedMsg := "" + if cached { + cachedMsg = " (cached)" + } + + log.Printf("Retrieved %s%s, price: %f\n", symbol, cachedMsg, currentPrice) + + // Temporary rate-limit + if c.limit != 0 { + time.Sleep(60 * time.Second / time.Duration(c.limit)) + } +} + +// sendPrometheusMetrics sends Prometheus metrics for a given symbol and its quote data. +func (c *collector) sendPrometheusMetrics( + ch chan<- prometheus.Metric, ls, lvs []string, changedPrice, changedPercent, currentVolume, currentPrice float64, +) { + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc("twelvedata_stock_change_price", "Changed price since last close price.", ls, nil), + prometheus.GaugeValue, + changedPrice, + lvs..., + ) + + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc("twelvedata_stock_change_percent", "Changed percent since last close price.", ls, nil), + prometheus.GaugeValue, + changedPercent, + lvs..., + ) + + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc("twelvedata_stock_volume", "Trading volume during the bar.", ls, nil), + prometheus.GaugeValue, + currentVolume, + lvs..., + ) + + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc("twelvedata_stock_price", "Real-time or the latest available price.", ls, nil), + prometheus.GaugeValue, + currentPrice, + lvs..., + ) +} diff --git a/internal/main.go b/internal/main.go new file mode 100644 index 0000000..52082bb --- /dev/null +++ b/internal/main.go @@ -0,0 +1,96 @@ +// Package internal is a server that uses the twelvedata API as its backend. +package internal + +import ( + "fmt" + "log" + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// Server struct +type Server struct { + Addr string + Port int + APIKey string + RateLimit int +} + +// New returns Twelvedata struct +func New(addr string, port int, apikey string, limit int) Server { + return Server{ + Addr: addr, + Port: port, + APIKey: apikey, + RateLimit: limit, + } +} + +// Boot the server +func (s *Server) Boot() { + reg := prometheus.NewRegistry() + + // Add standard process and Go metrics. + reg.MustRegister( + collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), + collectors.NewGoCollector(), + ) + + // Add handlers. + http.HandleFunc("/", s.help) + http.Handle("/metrics", promhttp.Handler()) + + http.HandleFunc("/price", func(w http.ResponseWriter, r *http.Request) { + s.priceHandler(w, r) + }) + + log.Print("Listening on port ", s.Port) + log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", s.Port), nil)) +} + +// help returns a help message for those using the root URL. +func (s *Server) help(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintf(w, "

Prometheus Quotes Exporter

") + fmt.Fprintf(w, "

To fetch quotes, your URL must be formatted as:

") + fmt.Fprintf(w, "http://localhost:%d/price?symbols=AAAA,BBBB,CCCC", s.Port) + fmt.Fprintf(w, "

Examples:

") + fmt.Fprintf(w, "