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, "")
-
- symbols := []string{
- "AMD",
- "AMZN,GOOG",
- }
-
- for _, symbol := range symbols {
- fmt.Fprintf(w, "- ", s.Port, symbol)
- fmt.Fprintf(w, "http://localhost:%d/price?symbols=%s
", s.Port, symbol)
- }
-}
-
-// priceHandler handles the "/price" endpoint. It creates a new collector with
-// the URL and a new prometheus registry to use that collector.
-func (s *Server) priceHandler(w http.ResponseWriter, r *http.Request) {
- log.Printf("URL: %s\n", r.RequestURI)
-
- collector, err := newCollector(r.URL, s.APIKey, s.RateLimit)
- if err != nil {
- log.Print(err)
- return
- }
-
- registry := prometheus.NewRegistry()
-
- // These will be collected every time the /stock or /fund endpoint is reached.
- registry.MustRegister(
- &collector,
- queryCount,
- queryDuration,
- errorCount)
-
- // Delegate http serving to Promethues client library, which will call collector.Collect.
- h := promhttp.HandlerFor(registry, promhttp.HandlerOpts{})
- h.ServeHTTP(w, r)
-}
diff --git a/internal/server.go b/internal/server.go
new file mode 100644
index 0000000..70371c1
--- /dev/null
+++ b/internal/server.go
@@ -0,0 +1,109 @@
+// Package internal contains the implementation of this exporter.
+package internal
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/collectors"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+ "github.com/umatare5/twelvedata-exporter/config"
+ "github.com/umatare5/twelvedata-exporter/log"
+)
+
+// Server struct
+type Server struct {
+ ListenAddrAndPort string
+ ScrapePath string
+ Client *TwelvedataClient
+}
+
+// NewServer returns Twelvedata struct
+func NewServer(config *config.Config) (Server, error) {
+ return Server{
+ ListenAddrAndPort: config.WebListenAddress + ":" + strconv.Itoa(config.WebListenPort),
+ ScrapePath: config.WebScrapePath,
+ Client: NewTwelvedataClient(config.TwelvedataAPIKey),
+ }, nil
+}
+
+// Start starts the server
+func (s *Server) Start() {
+ reg := prometheus.NewRegistry()
+
+ // Add standard process and Go metrics.
+ reg.MustRegister(
+ collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
+ collectors.NewGoCollector(),
+ )
+
+ // Register handlers.
+ http.HandleFunc("/", s.help)
+ http.HandleFunc(s.ScrapePath, func(w http.ResponseWriter, r *http.Request) {
+ s.priceHandler(w, r)
+ })
+
+ log.Infof("Listening on port %s", s.ListenAddrAndPort)
+ srv := &http.Server{
+ Addr: s.ListenAddrAndPort,
+ Handler: nil,
+ ReadTimeout: time.Second * 10,
+ WriteTimeout: time.Second * 10,
+ }
+ log.Fatal(srv.ListenAndServe())
+}
+
+// priceHandler handles the "/price" endpoint. It creates a new collector with
+// the URL and a new prometheus registry to use that collector.
+func (s *Server) priceHandler(w http.ResponseWriter, r *http.Request) {
+ // The typical query is formatted as: ?symbols=AAA,BBB...&symbols=CCC,DDD...
+ // We fetch all symbols into a single slice.
+ syms := r.URL.Query()["symbols"]
+ if len(syms) == 0 {
+ log.Infof("missing symbols in the query: %s", r.RequestURI)
+ return
+ }
+ log.Infof("URL: %s\n", r.RequestURI)
+
+ var symbols []string
+ for _, sym := range syms {
+ symbols = append(symbols, strings.Split(sym, ",")...)
+ }
+
+ registry := prometheus.NewRegistry()
+
+ // These will be collected every time the /stock or /fund endpoint is reached.
+ registry.MustRegister(
+ newCollector(s.Client, symbols),
+ queryCount,
+ queryDuration,
+ errorCount,
+ )
+
+ // Delegate http serving to Promethues client library, which will call collector.Collect.
+ h := promhttp.HandlerFor(registry, promhttp.HandlerOpts{})
+ h.ServeHTTP(w, r)
+}
+
+// help returns a help message for those using the root URL.
+func (s *Server) help(w http.ResponseWriter, _ *http.Request) {
+ fmt.Fprintf(w, "Prometheus Twelvedta Exporter
")
+ fmt.Fprintf(w, "To fetch the price of quotes, your URL must be formatted as:
")
+ fmt.Fprintf(w, "http://%s/price?symbols=AAAA,BBBB,CCCC", s.ListenAddrAndPort)
+ fmt.Fprintf(w, "Examples:
")
+ fmt.Fprintf(w, "")
+
+ symbols := []string{
+ "GOOGL",
+ "AMZN,AAPL,MSFT",
+ }
+
+ for _, symbol := range symbols {
+ fmt.Fprintf(w, "- ", s.ListenAddrAndPort, symbol)
+ fmt.Fprintf(w, "http://%s/price?symbols=%s
", s.ListenAddrAndPort, symbol)
+ }
+}
diff --git a/internal/twelvedata.go b/internal/twelvedata.go
new file mode 100644
index 0000000..dc7aa0f
--- /dev/null
+++ b/internal/twelvedata.go
@@ -0,0 +1,139 @@
+// Package internal contains the implementation of this exporter.
+package internal
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/umatare5/twelvedata-exporter/log"
+)
+
+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",
+ },
+ )
+)
+
+// TwelvedataGatherer is an interface for Twelvedata API
+type TwelvedataGatherer interface {
+ GetQuote(symbol string) (float64, error)
+}
+
+// TwelvedataClient is a client for Twelvedata API
+type TwelvedataClient struct {
+ baseURL string
+ apiKey string
+}
+
+// QuoteResponse is a response from twelvedata Quote endpoint
+type QuoteResponse 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"`
+}
+
+// NewTwelvedataClient returns Twelvedata Client.
+func NewTwelvedataClient(apiKey string) *TwelvedataClient {
+ return &TwelvedataClient{
+ baseURL: "https://api.twelvedata.com",
+ apiKey: apiKey,
+ }
+}
+
+// GetQuote sends GET request to Twelvedata API.
+func (t *TwelvedataClient) GetQuote(symbol string) (*QuoteResponse, error) {
+ client := &http.Client{
+ Timeout: time.Second * 10,
+ }
+
+ req, err := http.NewRequestWithContext(
+ context.Background(),
+ "GET",
+ fmt.Sprintf(t.baseURL+"/quote?symbol=%s&apikey=%s", symbol, t.apiKey),
+ nil,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ log.Errorf("Error sending request to server: %s", err)
+ return nil, err
+ }
+
+ defer func() {
+ err := resp.Body.Close()
+ if err != nil {
+ log.Errorf("Error closing response body: %s", err)
+ }
+ }()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ log.Errorf("Error closing response body: %s", err)
+ return nil, err
+ }
+
+ var data QuoteResponse
+ err = json.Unmarshal(body, &data)
+ if err != nil {
+ log.Errorf("Error parsing JSON: %s", err)
+ return nil, err
+ }
+
+ if data.Name == "" {
+ log.Errorf("Name is not included in JSON: %s", err)
+ return nil, err
+ }
+
+ queryDuration.Observe(time.Since(time.Now()).Seconds())
+
+ return &data, nil
+}
diff --git a/log/logger.go b/log/logger.go
new file mode 100644
index 0000000..b891fab
--- /dev/null
+++ b/log/logger.go
@@ -0,0 +1,28 @@
+// Package log provides a simple logging interface.
+package log
+
+import (
+ "github.com/sirupsen/logrus"
+)
+
+var logger = logrus.New()
+
+// Info logs a message at level Info.
+func Info(args ...interface{}) {
+ logger.Info(args...)
+}
+
+// Infof logs a message at level Info.
+func Infof(format string, args ...interface{}) {
+ logger.Infof(format, args...)
+}
+
+// Errorf logs a message at level Error.
+func Errorf(format string, args ...interface{}) {
+ logger.Errorf(format, args...)
+}
+
+// Fatal logs a message at level Fatal.
+func Fatal(args ...interface{}) {
+ logger.Fatal(args...)
+}
diff --git a/prometheus.rules.sample.yml b/prometheus.rules.sample.yml
new file mode 100644
index 0000000..0f26e22
--- /dev/null
+++ b/prometheus.rules.sample.yml
@@ -0,0 +1,25 @@
+groups:
+ - name: technical_indicators
+ rules:
+ - record: twelvedata:technical:rsi:7
+ expr: 100 - (100 / (1 +
+ (
+ (
+ (
+ sum_over_time((delta(twelvedata_price[1h]) > 0)[9d:])
+ *
+ (count_over_time((twelvedata_change_percent > 0)[9d:]) - 1)
+ )
+ / count_over_time((twelvedata_change_percent > 0)[9d:])
+ )
+ /
+ abs(
+ (
+ sum_over_time((delta(twelvedata_price[1h]) < 0)[9d:])
+ *
+ (count_over_time((twelvedata_change_percent < 0)[9d:]) - 1)
+ )
+ / count_over_time((twelvedata_change_percent < 0)[9d:])
+ )
+ )
+ ))
diff --git a/prometheus.sample.yml b/prometheus.sample.yml
new file mode 100644
index 0000000..e244746
--- /dev/null
+++ b/prometheus.sample.yml
@@ -0,0 +1,42 @@
+# my global config
+global:
+ scrape_interval: 15s # By default, scrape targets every 15 seconds.
+ evaluation_interval: 15s # By default, scrape targets every 15 seconds.
+ # scrape_timeout is set to the global default (10s).
+
+# Load rules once and periodically evaluate them according to the global 'evaluation_interval'.
+rule_files:
+ # - "first.rules"
+ # - "second.rules"
+
+# A scrape configuration containing exactly one endpoint to scrape:
+# Here it's Prometheus itself.
+scrape_configs:
+ # The job name is added as a label `job=` to any timeseries scraped from this config.
+ - job_name: 'prometheus'
+
+ # Override the global default and scrape targets from this job every 5 seconds.
+ scrape_interval: 5s
+
+ # metrics_path defaults to '/metrics'
+ # scheme defaults to 'http'.
+
+ static_configs:
+ - targets: ['localhost:9090']
+
+ - job_name: "twelvedata"
+ metrics_path: /price
+ scrape_interval: 15m
+ scrape_timeout: 14m
+ params:
+ symbols: # Free tier allows up to 8 symbols per minute
+ - SPX # S&P500
+ - SOXL # Direxion Daily Technology Bull 3X Shares ETF
+ - TECL # Direxion Daily Semiconductor Bull 3X Shares
+ - GOOGL # Google
+ - AAPL # Apple
+ - AMZN # Amazon
+ - FB # Facebook
+ - VIX # CBOE Volatility Index
+ static_configs:
+ - targets: [localhost:9341]