diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..63a13bd --- /dev/null +++ b/.air.toml @@ -0,0 +1,3 @@ +[build] +cmd = "go build -o tmp/twelvedata-exporter" +bin = "./tmp/twelvedata-exporter" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e69de29..6d6d7b7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -0,0 +1,34 @@ +name: release + +on: + workflow_dispatch: + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.22.3" + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.golangci.yml b/.golangci.yml index 72dd9fe..f3faffb 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,6 +1,7 @@ # Refer to https://golangci-lint.run/usage/configuration/ run: + go: "1.20" concurrency: 4 timeout: 5m skip-dirs: diff --git a/.goreleaser.yml b/.goreleaser.yml index e93e779..b0a43c7 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -10,10 +10,6 @@ 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 @@ -33,11 +29,15 @@ builds: gcflags: - all=-trimpath=. +dockers: +- id: twelvedata-exporter + image_templates: + - 'ghcr.io/umatare5/twelvedata-exporter:latest' + - 'ghcr.io/umatare5/twelvedata-exporter:{{ .Tag }}' + dockerfile: Dockerfile.goreleaser + archives: - name_template: '{{ .ProjectName }}-v{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' - format_overrides: - - goos: windows - format: zip release: prerelease: auto diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser new file mode 100644 index 0000000..4d8b372 --- /dev/null +++ b/Dockerfile.goreleaser @@ -0,0 +1,8 @@ +FROM alpine + +WORKDIR /app +COPY twelvedata-exporter /bin/ + +EXPOSE 9341 +USER ${UID} +ENTRYPOINT [ "/bin/twelvedata-exporter" ] diff --git a/LICENSE b/LICENSE index f628684..2dfc689 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 umatare5 +Copyright (c) 2024 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 diff --git a/README.md b/README.md index 54d6d98..03f8e17 100644 --- a/README.md +++ b/README.md @@ -2,92 +2,163 @@ ![](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.) +twelvedata-exporter is a Prometheus Exporter to fetch quotes from Twelvedata API. -## Data Provider Setup +This exporter allows a prometheus instance to monitor prices of stocks, ETFs, and mutual funds. -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. +> [!Important] +> +> To access the Twelvedata API, you need an access token. Please register with [Twelvedata](https://twelvedata.com/) in advance and generate an access token by referring to [the official document: Getting Started - Authentication](https://twelvedata.com/docs#authentication). -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. +## Installation -## Building the exporter +```bash +docker run ghcr.io/umatare5/twelvedata-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`: +> [!Tip] +> If you would like to use binaries, please download them from [release page](https://github.com/umatare5/twelvedata-exporter/releases). +> +> - `linux_amd64`, `linux_arm64`, `darwin_amd64`, `darwin_arm64` and `windows_amd64` are supported. + +## Syntax ```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 +NAME: + Fetch quotes from Twelvedata API - twelvedata-exporter + +USAGE: + twelvedata-exporter COMMAND [options...] + +VERSION: + 0.1.0 + +COMMANDS: + help, h Shows a list of commands or help for one command + +GLOBAL OPTIONS: + --web.listen-address value, -I value Set IP address (default: "0.0.0.0") + --web.listen-port value, -P value Set port number (default: 9341) + --web.scrape-path value, -p value Set the path to expose metrics (default: "/price") + --twelvedata.api-key value, -a value Set key to use twelvedata API [$TWELVEDATA_API_KEY] + --help, -h show help + --version, -v print the version ``` -## Docker image +## Configuration + +This exporter supports following environment variables: + +| Environment Variable | Description | +| :------------------- | ------------------------------------ | +| `TWELVEDATA_API_KEY` | The API Key to be used for requests. | + +## Metrics + +This exporter returns following metrics: + +| Metric Name | Description | Type | Example Value | +| --------------------------------- | ---------------------------------------- | ----- | --------------- | +| `twelvedata_change_percent` | Changed percent since last close price. | Gauge | `1.00975` | +| `twelvedata_change_price` | Changed price since last close price. | Gauge | `1.72` | +| `twelvedata_price` | Real-time or the latest available price. | Gauge | `172.06` | +| `twelvedata_previous_close_price` | Closing price of the previous day. | Gauge | `170.34` | +| `twelvedata_volume` | Trading volume during the bar. | Gauge | `1.5206856e+07` | + +
Click to show full metrics

+ +```plain +# HELP twelvedata_change_percent Changed percent since last close price. +# TYPE twelvedata_change_percent gauge +twelvedata_change_percent{currency="USD",exchange="NASDAQ",name="Alphabet Inc",symbol="GOOGL"} 1.00975 +# HELP twelvedata_change_price Changed price since last close price. +# TYPE twelvedata_change_price gauge +twelvedata_change_price{currency="USD",exchange="NASDAQ",name="Alphabet Inc",symbol="GOOGL"} 1.72 +# HELP twelvedata_failed_queries_total Count of failed queries +# TYPE twelvedata_failed_queries_total counter +twelvedata_failed_queries_total 0 +# HELP twelvedata_previous_close_price Closing price of the previous day. +# TYPE twelvedata_previous_close_price gauge +twelvedata_previous_close_price{currency="USD",exchange="NASDAQ",name="Alphabet Inc",symbol="GOOGL"} 170.34 +# HELP twelvedata_price Real-time or the latest available price. +# TYPE twelvedata_price gauge +twelvedata_price{currency="USD",exchange="NASDAQ",name="Alphabet Inc",symbol="GOOGL"} 172.06 +# HELP twelvedata_queries_total Count of completed queries +# TYPE twelvedata_queries_total counter +twelvedata_queries_total 1 +# HELP twelvedata_query_duration_seconds Duration of queries to the upstream API +# TYPE twelvedata_query_duration_seconds summary +twelvedata_query_duration_seconds_sum 0 +twelvedata_query_duration_seconds_count 0 +# HELP twelvedata_volume Trading volume during the bar. +# TYPE twelvedata_volume gauge +twelvedata_volume{currency="USD",exchange="NASDAQ",name="Alphabet Inc",symbol="GOOGL"} 1.5206856e+07 +``` + +

+ +## Usage -The repository includes a ready to use `Dockerfile`. To build a new image, type: +### Exporter + +To refer to the usage, please access http://localhost:9341/ after starting the exporter. + +```bash +❯ ./twelvedata-exporter --twelvedata.api-key "foobarbaz0123456789abcdefghijklm" +INFO[0000] Listening on port 0.0.0.0:9341 +``` + +### Prometheus + +Please refer to [prometheus.sample.yml#L27-L42](./prometheus.sample.yml#L27-L42). + +- To know how to write technical indicators as PromQL, please refer to [prometheus.rules.sample.yml](./prometheus.rules.sample.yml). + +> [!Tip] +> +> The Twelvedata API has rate limits based on the license. Please adjust the `scrape_interval` and `scrape_timeout` to comply with these limits. For further the limits, please refer to [twelvedata - Pricing](https://twelvedata.com/pricing). + +## Development + +### Build + +The repository includes a ready to use `Dockerfile`. Run the following command to build a new image: ```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. +The new image is named as `$USER/twelvedata-exporter` and exports `9341/tcp` to your host. -## Running the exporter +### Release -To run the exporter, just type: +I'm releasing this exporter manually. -```base -twelvedata-exporter +```shell +git tag vX.Y.Z && git push --tags ``` -The exporter listens on port 9341 by default. You can use the `--port` command-line -flag to change the port number, if necessary. +Run the release workflow. -## Testing +- [GitHub Actions: release workflow](https://github.com/umatare5/twelvedata-exporter/actions/workflows/release.yaml) -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: +## Contribution -[http://localhost:9341/price?symbols=GOOGL](http://localhost:9341/price?symbols=GOOGL) +1. Fork ([https://github.com/umatare5/twelvedata-exporter/fork](https://github.com/umatare5/twelvedata-exporter/fork)) +2. Create a feature branch +3. Commit your changes +4. Rebase your local changes against the master branch +5. Create a new Pull Request -The result should be similar to: +## Licence -``` -# 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 -``` +[MIT](LICENSE) + +## Author + +[umatare5](https://github.com/umatare5) ## 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. +I used to use [Marco Paganini](https://github.com/marcopaganini)'s [quotes-exporter](https://github.com/marcopaganini/quotes-exporter) before. However, due to changes in the external endpoint, that exporter was broken and archived. +Now, I built this exporter taking Marco's exporter as a reference. My thanks to Marco the predecessor, and [Tristan Colgate-McFarlane](https://github.com/tcolgate) the creator of [yquotes-exporter](https://github.com/tcolgate/yquotes_exporter) who preceded Marco. diff --git a/VERSION b/VERSION index 6e8bf73..3eefcb9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +1.0.0 diff --git a/air.toml b/air.toml deleted file mode 100644 index d864e48..0000000 --- a/air.toml +++ /dev/null @@ -1,3 +0,0 @@ -[build] -cmd = "go build -o twelvedata-exporter ./cmd/main.go" -bin = "twelvedata-exporter --limit 8" diff --git a/cli/main.go b/cli/main.go index 6699883..12c9f04 100644 --- a/cli/main.go +++ b/cli/main.go @@ -5,35 +5,25 @@ import ( "fmt" "os" - server "github.com/umatare5/twelvedata-exporter/internal" + "github.com/umatare5/twelvedata-exporter/config" + "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 +// Start is the entrypoint of this CLI func Start() { cmd := &cli.App{ Name: "twelvedata-exporter", - HelpName: "Fetch metrics from Twelvedata API", + HelpName: "Fetch quotes from Twelvedata API", Usage: "twelvedata-exporter", UsageText: "twelvedata-exporter COMMAND [options...]", - Version: "0.1.0", + Version: "1.0.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() + config := config.NewConfig(ctx) + server, _ := internal.NewServer(&config) + + server.Start() return nil }, @@ -51,8 +41,8 @@ func registerFlags() []cli.Flag { flags := []cli.Flag{} flags = append(flags, registerWebListenAddressFlag()...) flags = append(flags, registerWebListenPortFlag()...) + flags = append(flags, registerWebScrapePathFlag()...) flags = append(flags, registerAPIKeyFlag()...) - flags = append(flags, registerRateLimitFlag()...) return flags } @@ -60,7 +50,7 @@ func registerFlags() []cli.Flag { func registerWebListenAddressFlag() []cli.Flag { return []cli.Flag{ &cli.StringFlag{ - Name: webListenAddressFlagName, + Name: config.WebListenAddressFlagName, Usage: "Set IP address", Aliases: []string{"I"}, Value: "0.0.0.0", @@ -72,7 +62,7 @@ func registerWebListenAddressFlag() []cli.Flag { func registerWebListenPortFlag() []cli.Flag { return []cli.Flag{ &cli.IntFlag{ - Name: webListenPortFlagName, + Name: config.WebListenPortFlagName, Usage: "Set port number", Aliases: []string{"P"}, Value: 9341, @@ -80,11 +70,23 @@ func registerWebListenPortFlag() []cli.Flag { } } +// registerWebScrapePathFlag +func registerWebScrapePathFlag() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: config.WebScrapePathFlagName, + Usage: "Set the path to expose metrics", + Aliases: []string{"p"}, + Value: "/price", + }, + } +} + // registerAPIKeyFlag func registerAPIKeyFlag() []cli.Flag { return []cli.Flag{ &cli.StringFlag{ - Name: twelvedataAPIKeyFlagName, + Name: config.TwelvedataAPIKeyFlagName, Usage: "Set key to use twelvedata API", Aliases: []string{"a"}, EnvVars: []string{"TWELVEDATA_API_KEY"}, @@ -92,15 +94,3 @@ func registerAPIKeyFlag() []cli.Flag { }, } } - -// 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/cli/config.go b/config/config.go similarity index 52% rename from cli/config.go rename to config/config.go index cabfd27..83ae7f6 100644 --- a/cli/config.go +++ b/config/config.go @@ -1,5 +1,5 @@ -// Package cli is responsible for the execution of the CLI. -package cli +// Package config is responsible for the execution of the CLI. +package config import ( "errors" @@ -9,21 +9,29 @@ import ( "github.com/urfave/cli/v2" ) +// Config flag names +const ( + WebListenAddressFlagName = "web.listen-address" + WebListenPortFlagName = "web.listen-port" + WebScrapePathFlagName = "web.scrape-path" + TwelvedataAPIKeyFlagName = "twelvedata.api-key" +) + // Config struct type Config struct { - WebListenAddress string - WebListenPort int - TwelvedataAPIKey string - TwelvedataRateLimit int + WebListenAddress string + WebListenPort int + WebScrapePath string + TwelvedataAPIKey string } -// New returns Config struct -func newConfig(ctx *cli.Context) Config { +// NewConfig 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), + WebListenAddress: ctx.String(WebListenAddressFlagName), + WebListenPort: ctx.Int(WebListenPortFlagName), + WebScrapePath: ctx.String(WebScrapePathFlagName), + TwelvedataAPIKey: ctx.String(TwelvedataAPIKeyFlagName), } err := configor.New(&configor.Config{}).Load(&config) @@ -39,11 +47,11 @@ func newConfig(ctx *cli.Context) Config { log.Fatal(err) } - if err := isValidTwelvedataAPIKeyFlag(config.TwelvedataAPIKey); err != nil { + if err := isValidWebScrapePathFlag(config.WebScrapePath); err != nil { log.Fatal(err) } - if err := isValidTwelvedataRateLimitFlag(config.TwelvedataRateLimit); err != nil { + if err := isValidTwelvedataAPIKeyFlag(config.TwelvedataAPIKey); err != nil { log.Fatal(err) } @@ -58,6 +66,10 @@ func isValidWebListenPortFlag(_ int) error { return nil } +func isValidWebScrapePathFlag(_ string) error { + return nil +} + func isValidTwelvedataAPIKeyFlag(apikey string) error { if apikey == "" { return errors.New("Environment variable 'TWELVEDATA_API_KEY' is not set") @@ -65,7 +77,3 @@ func isValidTwelvedataAPIKeyFlag(apikey string) error { return nil } - -func isValidTwelvedataRateLimitFlag(_ int) error { - return nil -} diff --git a/go.mod b/go.mod index dd8ab2e..76a7d14 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,8 @@ 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/sirupsen/logrus v1.9.3 github.com/urfave/cli/v2 v2.25.7 ) @@ -16,13 +16,11 @@ require ( 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 index e8a4766..7586d08 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,7 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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= @@ -51,8 +52,6 @@ github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/ 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= @@ -69,11 +68,10 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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= @@ -98,13 +96,15 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD 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/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 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/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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= @@ -123,8 +123,6 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ 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= @@ -136,6 +134,7 @@ golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7w 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.0.0-20220715151400-c0bba94af5f8/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= @@ -162,3 +161,5 @@ 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= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/client.go b/internal/client.go deleted file mode 100644 index b26efb7..0000000 --- a/internal/client.go +++ /dev/null @@ -1,76 +0,0 @@ -// 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 index 8ddd7e6..a796a78 100644 --- a/internal/collector.go +++ b/internal/collector.go @@ -1,181 +1,128 @@ -// Package internal is a server that uses the twelvedata API as its backend. +// Package internal contains the implementation of this exporter. package internal import ( - "fmt" - "log" - "net/url" "strconv" - "strings" - "time" - "github.com/kofalt/go-memoize" "github.com/prometheus/client_golang/prometheus" + "github.com/umatare5/twelvedata-exporter/log" ) +const ( + namespace = "twelvedata" +) + +// Metrics descriptions 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", - }, + change_price = prometheus.NewDesc( //nolint:golint,revive + prometheus.BuildFQName(namespace, "", "change_price"), + "Changed price since last close price.", + []string{"symbol", "name", "exchange", "currency"}, nil, + ) + + change_percent = prometheus.NewDesc( //nolint:golint,revive + prometheus.BuildFQName(namespace, "", "change_percent"), + "Changed percent since last close price.", + []string{"symbol", "name", "exchange", "currency"}, nil, ) - queryCount = prometheus.NewCounter( - prometheus.CounterOpts{ - Name: "twelvedata_queries_total", - Help: "Count of completed queries", - }, + + volume = prometheus.NewDesc( //nolint:golint,revive + prometheus.BuildFQName(namespace, "", "volume"), + "Trading volume during the bar.", + []string{"symbol", "name", "exchange", "currency"}, nil, + ) + + previous_close_price = prometheus.NewDesc( //nolint:golint,revive + prometheus.BuildFQName(namespace, "", "previous_close_price"), + "Closing price of the previous day.", + []string{"symbol", "name", "exchange", "currency"}, nil, ) - errorCount = prometheus.NewCounter( - prometheus.CounterOpts{ - Name: "twelvedata_failed_queries_total", - Help: "Count of failed queries", - }, + + price = prometheus.NewDesc( //nolint:golint,revive + prometheus.BuildFQName(namespace, "", "price"), + "Real-time or the latest available price.", + []string{"symbol", "name", "exchange", "currency"}, nil, ) - // Cache external API consuming calls for 10 minutes. - cache *memoize.Memoizer = memoize.NewMemoizer(10*time.Minute, 20*time.Minute) + httpRequestsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: namespace, + Name: "http_requests_total", + Help: "The total number of requests labeled by response code", + }, + []string{"symbol", "name", "exchange", "currency"}, + ) ) -// collector holds data for a prometheus collector. -type collector struct { - apikey string - limit int +// Collector collects Quote Metrics +type Collector struct { + client *TwelvedataClient 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, ",")...) +// newCollector returns an initialized exporter +func newCollector(client *TwelvedataClient, symbols []string) *Collector { + return &Collector{ + client: client, + symbols: symbols, } - - 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) +func (c *Collector) Describe(ch chan<- *prometheus.Desc) { + ch <- change_price + ch <- change_percent + ch <- volume + ch <- price + httpRequestsTotal.Describe(ch) } -// Collect retrieves quote data and outputs Prometheus compatible time series on -// the output channel. -func (c *collector) Collect(ch chan<- prometheus.Metric) { +// 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) + quote, _ := c.client.GetQuote(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) + c.processMetrics(quote, ch) } } -// 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 - } +func (c *Collector) processMetrics(quote *QuoteResponse, ch chan<- prometheus.Metric) { + isCached := false - start := time.Now() - qret, err, cached := cache.Memoize(symbol, cachedFetcher) - queryDuration.Observe(time.Since(start).Seconds()) + labels := c.createLabelValues(quote.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) - if err != nil { - errorCount.Inc() - log.Printf("Error looking up %s: %v\n", symbol, err) - return nil, false - } + ch <- prometheus.MustNewConstMetric(change_price, prometheus.GaugeValue, changedPrice, labels...) + ch <- prometheus.MustNewConstMetric(change_percent, prometheus.GaugeValue, changedPercent, labels...) + ch <- prometheus.MustNewConstMetric(volume, prometheus.GaugeValue, currentVolume, labels...) + ch <- prometheus.MustNewConstMetric(previous_close_price, prometheus.GaugeValue, previousClosePrice, labels...) + ch <- prometheus.MustNewConstMetric(price, prometheus.GaugeValue, previousClosePrice+changedPrice, labels...) - quote, ok := qret.(*Quote) - if !ok { - errorCount.Inc() - log.Printf("Invalid quote data for %s: %v\n", symbol, qret) - return nil, false - } + httpRequestsTotal.Collect(ch) - return quote, cached + // TODO: Implement caching. isCached is always false. + c.logRetrievedData(quote.Symbol, isCached, previousClosePrice+changedPrice) } // createLabelValues creates label values for a given symbol and its quote data. -func (c *collector) createLabelValues(symbol string, quote *Quote) []string { +func (c *Collector) createLabelValues(symbol string, quote *QuoteResponse) []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) { +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..., - ) + log.Infof("Retrieved %s%s, price: %f\n", symbol, cachedMsg, currentPrice) } diff --git a/internal/main.go b/internal/main.go deleted file mode 100644 index 52082bb..0000000 --- a/internal/main.go +++ /dev/null @@ -1,96 +0,0 @@ -// 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, "