├── .gitignore ├── .travis.yml ├── nginx-nr-agent_test.go ├── LICENSE ├── Gopkg.toml ├── Gopkg.lock ├── README.md └── nginx-nr-agent.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.swo 3 | nginx-nr-agent 4 | nginx-nr-agent-linux-amd64 5 | /vendor 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.10.x 5 | 6 | sudo: required 7 | 8 | before_install: 9 | - sudo apt-get install -y ca-certificates 10 | 11 | install: 12 | - go get github.com/golang/dep/cmd/dep && dep ensure 13 | 14 | script: 15 | - go test -v --race --timeout 30s 16 | -------------------------------------------------------------------------------- /nginx-nr-agent_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | httpmock "gopkg.in/jarcoal/httpmock.v1" 7 | ) 8 | 9 | const ( 10 | nginxStatusReponseString = `Active connections: 2 11 | server accepts handled requests 12 | 31 30 42 13 | Reading: 0 Writing: 10 Waiting: 1` 14 | ) 15 | 16 | func Test_GetStats(t *testing.T) { 17 | httpmock.Activate() 18 | defer httpmock.DeactivateAndReset() 19 | 20 | httpmock.RegisterResponder("GET", "http://localhost/status", 21 | httpmock.NewStringResponder(200, nginxStatusReponseString)) 22 | metric, _ := GetStats("http://localhost/status") 23 | 24 | var metricTests = []struct { 25 | want int64 26 | got int64 27 | }{ 28 | {metric.Connections, 2}, 29 | {metric.Accepts, 31}, 30 | {metric.Handled, 30}, 31 | {metric.Requests, 42}, 32 | {metric.Reading, 0}, 33 | {metric.Writing, 10}, 34 | {metric.Waiting, 1}, 35 | } 36 | 37 | for _, input := range metricTests { 38 | if input.want != input.got { 39 | t.Errorf("Want %d, Got %d", input.want, input.got) 40 | } 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Nitro Software 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Gopkg.toml example 2 | # 3 | # Refer to https://golang.github.io/dep/docs/Gopkg.toml.html 4 | # for detailed Gopkg.toml documentation. 5 | # 6 | # required = ["github.com/user/thing/cmd/thing"] 7 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 8 | # 9 | # [[constraint]] 10 | # name = "github.com/user/project" 11 | # version = "1.0.0" 12 | # 13 | # [[constraint]] 14 | # name = "github.com/user/project2" 15 | # branch = "dev" 16 | # source = "github.com/myfork/project2" 17 | # 18 | # [[override]] 19 | # name = "github.com/x/y" 20 | # version = "2.4.0" 21 | # 22 | # [prune] 23 | # non-go = false 24 | # go-tests = true 25 | # unused-packages = true 26 | 27 | 28 | [[constraint]] 29 | name = "github.com/Sirupsen/logrus" 30 | version = "1.0.6" 31 | 32 | [[constraint]] 33 | name = "github.com/kelseyhightower/envconfig" 34 | version = "1.3.0" 35 | 36 | [[constraint]] 37 | branch = "v1" 38 | name = "gopkg.in/jarcoal/httpmock.v1" 39 | 40 | [[constraint]] 41 | name = "gopkg.in/relistan/rubberneck.v1" 42 | version = "1.1.0" 43 | 44 | [prune] 45 | go-tests = true 46 | unused-packages = true 47 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | name = "github.com/Sirupsen/logrus" 6 | packages = ["."] 7 | revision = "3e01752db0189b9157070a0e1668a620f9a85da2" 8 | version = "v1.0.6" 9 | 10 | [[projects]] 11 | name = "github.com/kelseyhightower/envconfig" 12 | packages = ["."] 13 | revision = "f611eb38b3875cc3bd991ca91c51d06446afa14c" 14 | version = "v1.3.0" 15 | 16 | [[projects]] 17 | branch = "master" 18 | name = "golang.org/x/crypto" 19 | packages = ["ssh/terminal"] 20 | revision = "0e37d006457bf46f9e6692014ba72ef82c33022c" 21 | 22 | [[projects]] 23 | branch = "master" 24 | name = "golang.org/x/sys" 25 | packages = [ 26 | "unix", 27 | "windows" 28 | ] 29 | revision = "1561086e645b2809fb9f8a1e2a38160bf8d53bf4" 30 | 31 | [[projects]] 32 | branch = "v1" 33 | name = "gopkg.in/jarcoal/httpmock.v1" 34 | packages = ["."] 35 | revision = "8007e27cdb3239fcfc95c1274d7d8cbcc68b7d52" 36 | 37 | [[projects]] 38 | name = "gopkg.in/relistan/rubberneck.v1" 39 | packages = ["."] 40 | revision = "03df8f975a7d48ea65bc65bfbb3459b49a28037a" 41 | version = "v1.1.0" 42 | 43 | [solve-meta] 44 | analyzer-name = "dep" 45 | analyzer-version = 1 46 | inputs-digest = "537b4c48ceaaf1aa6376ac9adeee1e116121538ef41a4dd247f83a1b3e676bcb" 47 | solver-name = "gps-cdcl" 48 | solver-version = 1 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Nginx New Relic Plugin 2 | ---------------------- 3 | 4 | [![](https://travis-ci.org/Nitro/nginx-nr-agent.svg?branch=master)](https://travis-ci.org/Nitro/nginx-nr-agent) 5 | [![](https://goreportcard.com/badge/github.com/Nitro/nginx-nr-agent)](https://goreportcard.com/report/github.com/Nitro/nginx-nr-agent) 6 | 7 | This is a re-implementation of the official [NGiNX New Relic plugin][1] in Go so 8 | that it compiles down to a static binary with no Python runtime required. It 9 | only grabs the stats that are including the [http-stub-status][2] module and does 10 | not add all the stats available from NGiNX Plus. 11 | 12 | Aside from being just a static binary, it's also a [12-factor][3] app which is good 13 | for running in containers: 14 | 15 | * All configuration is from environment variables 16 | * Logging is to stdout 17 | 18 | On startup the plugin prints its configuration to the log so you can see how 19 | it ran. 20 | 21 | Build 22 | ------ 23 | 24 | You need [Golang/dep](https://github.com/golang/dep) in order to build the 25 | project. Run the following commands to fetch dependencies: 26 | 27 | ``` 28 | $ go get github.com/golang/dep/cmd/dep 29 | $ dep ensure 30 | ``` 31 | 32 | Then build: 33 | 34 | ``` 35 | $ go build 36 | ``` 37 | 38 | Example Config 39 | -------------- 40 | 41 | ``` 42 | AGENT_NEW_RELIC_APP_NAME="NGiNX Gateway Foo" \ 43 | AGENT_STATS_URL=http://localhost:32768/health \ 44 | AGENT_NEW_RELIC_LICENSE_KEY="" \ 45 | ./nginx-nr-agent 46 | ``` 47 | 48 | You can also change the New Relic API URL used like this: 49 | 50 | ``` 51 | AGENT_NEW_RELIC_API_URL="" 52 | ``` 53 | 54 | Generally this is not a setting you'll need to change. 55 | 56 | [1]: https://github.com/skyzyx/nginx-nr-agent 57 | [2]: http://nginx.org/en/docs/http/ngx_http_stub_status_module.html 58 | [3]: https://www.12factor.net 59 | -------------------------------------------------------------------------------- /nginx-nr-agent.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Go replacement for the nginx New Relic plugin. No Python runtime required. 4 | // Only reports on a single instance of Nginx, and takes configuration from 5 | // environment variables. 6 | 7 | import ( 8 | "bytes" 9 | "encoding/json" 10 | "fmt" 11 | "io/ioutil" 12 | "net/http" 13 | "os" 14 | "reflect" 15 | "regexp" 16 | "strconv" 17 | "time" 18 | 19 | log "github.com/Sirupsen/logrus" 20 | "github.com/kelseyhightower/envconfig" 21 | "gopkg.in/relistan/rubberneck.v1" 22 | ) 23 | 24 | const ( 25 | AgentGuid = "com.nginx.newrelic-agent" 26 | AgentVersion = "2.0.0" 27 | PollSeconds = 60 28 | PollInterval = PollSeconds * time.Second // How often we're polling. New Relic expects 1 minute 29 | ErrorBackoffTime = 10 * time.Second // How long to back off on errored stats fetch 30 | ) 31 | 32 | var ( 33 | ParserRegexp = regexp.MustCompile( 34 | "^Active connections: (?P\\d+)\\s+[\\w ]+\n" + 35 | "\\s+(?P\\d+)" + 36 | "\\s+(?P\\d+)" + 37 | "\\s+(?P\\d+)" + 38 | "\\s+Reading:\\s+(?P\\d+)" + 39 | "\\s+Writing:\\s+(?P\\d+)" + 40 | "\\s+Waiting:\\s+(?P\\d+)", 41 | ) 42 | 43 | accepted int64 44 | sumAccepted int64 45 | dropped int64 46 | total int64 47 | active int64 48 | idle int64 49 | current int64 50 | 51 | config Config 52 | ) 53 | 54 | type Config struct { 55 | NewRelicAppName string `split_words:"true"` 56 | NewRelicApiUrl string `split_words:"true" default:"https://platform-api.newrelic.com/platform/v1/metrics"` 57 | NewRelicLicenseKey string `split_words:"true"` 58 | StatsUrl string `split_words:"true" default:"http://localhost:8000/status"` 59 | Debug bool `envconfig:"DEBUG" default:"false"` 60 | } 61 | 62 | type MetricReading struct { 63 | Connections int64 64 | Accepts int64 65 | Handled int64 66 | Requests int64 67 | Reading int64 68 | Writing int64 69 | Waiting int64 70 | } 71 | 72 | // The data we'll report to New Relic 73 | type NrMetric struct { 74 | Accepted int64 `newrelic:"Component/Connections/Accepted[Connections/sec]"` 75 | Dropped int64 `newrelic:"Component/Connections/Dropped[Connections/sec]"` 76 | Total int64 `newrelic:"Component/Requests/Total[Connections]"` 77 | Active int64 `newrelic:"Component/Connections/Active[Connections]"` 78 | Idle int64 `newrelic:"Component/Connections/Idle[Connections]"` 79 | Current int64 `newrelic:"Component/Requests/Current[Requests]"` 80 | SummaryIdle int64 `newrelic:"Component/ConnSummary/Idle[Connections]"` 81 | SummaryActive int64 `newrelic:"Component/ConnSummary/Active[Connections]"` 82 | } 83 | 84 | type NrUpload struct { 85 | Agent map[string]string `json:"agent"` 86 | Components []*NrComponent `json:"components"` 87 | } 88 | 89 | type NrComponent struct { 90 | Guid string `json:"guid"` 91 | Duration int `json:"duration"` 92 | Name string `json:"name"` 93 | Metrics map[string]int64 `json:"metrics"` 94 | } 95 | 96 | func NewNrComponent(metrics map[string]int64) *NrComponent { 97 | return &NrComponent{ 98 | Guid: AgentGuid, 99 | Duration: (int)(PollInterval / time.Second), 100 | Name: config.NewRelicAppName, 101 | Metrics: metrics, 102 | } 103 | } 104 | 105 | func NewNrUpload(components []*NrComponent) *NrUpload { 106 | hostname, _ := os.Hostname() 107 | 108 | return &NrUpload{ 109 | Agent: map[string]string{ 110 | "version": AgentVersion, 111 | "host": hostname, 112 | "pid": strconv.Itoa(os.Getpid()), 113 | }, 114 | Components: components, 115 | } 116 | } 117 | 118 | // Connect up to nginx and fetch the stub status output 119 | func GetStats(url string) (*MetricReading, error) { 120 | client := &http.Client{ 121 | Timeout: 7 * time.Second, 122 | } 123 | response, err := client.Get(url) 124 | if err != nil { 125 | return nil, err 126 | } 127 | 128 | defer response.Body.Close() 129 | 130 | body, err := ioutil.ReadAll(response.Body) 131 | if err != nil { 132 | return nil, err 133 | } 134 | 135 | match := ParserRegexp.FindStringSubmatch(string(body)) 136 | result := make(map[string]int64) 137 | for i, name := range ParserRegexp.SubexpNames() { 138 | if i != 0 { 139 | val, _ := strconv.Atoi(match[i]) 140 | result[name] = int64(val) 141 | } 142 | } 143 | 144 | metric := MetricReading{ 145 | Connections: result["connections"], 146 | Accepts: result["accepts"], 147 | Handled: result["handled"], 148 | Requests: result["requests"], 149 | Reading: result["reading"], 150 | Writing: result["writing"], 151 | Waiting: result["waiting"], 152 | } 153 | 154 | return &metric, nil 155 | } 156 | 157 | // Transform the reading from Nginx into the metric values, and update 158 | func processOne(metric *MetricReading) { 159 | // We don't want to report giant spikes on the graph on startup 160 | if sumAccepted != 0 { 161 | // Accepted is a counter... we need to subtract the total each time 162 | accepted = (metric.Accepts - sumAccepted) / PollSeconds // report rps not rpm 163 | } 164 | sumAccepted = metric.Accepts 165 | 166 | dropped = metric.Accepts - metric.Handled - dropped 167 | active = metric.Connections 168 | idle = metric.Waiting 169 | total = active + idle 170 | current = metric.Reading + metric.Writing 171 | 172 | log.Debugf(` 173 | Accepted: %d 174 | Dropped: %d 175 | Total: %d 176 | Active: %d 177 | Idle: %d 178 | Current: %d 179 | `, accepted, dropped, total, active, idle, current, 180 | ) 181 | } 182 | 183 | // Format an NrMetric and put it into the upload channel 184 | func notifyNewRelic(nrChan chan *NrMetric) { 185 | batch := NrMetric{ 186 | Accepted: accepted, 187 | Dropped: dropped, 188 | Total: total, 189 | Active: active, 190 | Idle: idle, 191 | Current: current, 192 | SummaryIdle: idle, 193 | SummaryActive: active, 194 | } 195 | 196 | select { 197 | case nrChan <- &batch: 198 | // great! 199 | case <-time.After(1 * time.Second): 200 | log.Warn("Nothing is consuming New Relic reporting events. Giving up reporting") 201 | } 202 | } 203 | 204 | // Runs in the background, uploading things as they arrive in the channel 205 | func processUploads(nrChan chan *NrMetric) { 206 | // Uses reflection to read the struct tags... slow, but not high throughput 207 | for batch := range nrChan { 208 | st := reflect.TypeOf(*batch) 209 | item := reflect.ValueOf(*batch) 210 | metrics := make(map[string]int64, st.NumField()) 211 | 212 | for i := 0; i < st.NumField(); i++ { 213 | metrics[st.Field(i).Tag.Get("newrelic")] = item.Field(i).Int() 214 | } 215 | 216 | upload := NewNrUpload([]*NrComponent{NewNrComponent(metrics)}) 217 | 218 | err := uploadOne(upload) 219 | if err != nil { 220 | log.Errorf("Failed to upload to New Relic: %s", err) 221 | } 222 | } 223 | } 224 | 225 | // Handle uploading one metric batch 226 | func uploadOne(upload *NrUpload) error { 227 | log.Debugf("Uploading to New Relic") 228 | client := &http.Client{ 229 | Timeout: 15 * time.Second, 230 | } 231 | 232 | bodyJson, err := json.Marshal(upload) 233 | if err != nil { 234 | return fmt.Errorf("Unable to encode upload: %s", err) 235 | } 236 | 237 | log.Debug(string(bodyJson)) 238 | 239 | uploadBody := bytes.NewBuffer(bodyJson) 240 | 241 | req, err := http.NewRequest("POST", config.NewRelicApiUrl, uploadBody) 242 | if err != nil { 243 | return err 244 | } 245 | 246 | // Send the required headers so the New Relic API will take our data 247 | req.Header.Add("Content-Type", "application/json") 248 | req.Header.Add("Accept", "application/json") 249 | req.Header.Add("User-Agent", "newrelic-nginx-agent/"+AgentVersion) 250 | req.Header.Add("X-License-Key", config.NewRelicLicenseKey) 251 | 252 | response, err := client.Do(req) 253 | if err != nil { 254 | return err 255 | } 256 | 257 | uploadBody.Reset() 258 | 259 | defer response.Body.Close() 260 | 261 | if response.StatusCode > 299 || response.StatusCode < 200 { 262 | body, _ := ioutil.ReadAll(response.Body) 263 | 264 | return fmt.Errorf("Got invalid response from New Relic (%d): %s", 265 | response.StatusCode, string(body)) 266 | } 267 | 268 | if err != nil { 269 | return err 270 | } 271 | 272 | log.Debugf("Successful upload to New Relic") 273 | 274 | return nil 275 | } 276 | 277 | // Immediately, and on a timed loop, update the metrics. 278 | func processStats(quit chan struct{}, nrChan chan *NrMetric) { 279 | for { 280 | select { 281 | case <-time.After(PollInterval): 282 | log.Debug("Connecting to Nginx to fetch stats") 283 | metric, err := GetStats(config.StatsUrl) 284 | if err != nil { 285 | log.Errorf("Unable to fetch stats from nginx: %s", err) 286 | time.Sleep(ErrorBackoffTime) 287 | continue 288 | } 289 | processOne(metric) 290 | notifyNewRelic(nrChan) 291 | case <-quit: 292 | log.Warn("Received quit signal, shutting down") 293 | return 294 | } 295 | } 296 | } 297 | 298 | func main() { 299 | envconfig.Process("agent", &config) 300 | rubberneck.Print(config) 301 | 302 | if config.Debug { 303 | log.SetLevel(log.DebugLevel) 304 | } else { 305 | log.SetLevel(log.InfoLevel) 306 | } 307 | 308 | nrChan := make(chan *NrMetric) 309 | quitChan := make(chan struct{}) 310 | 311 | go processStats(quitChan, nrChan) 312 | 313 | if config.NewRelicLicenseKey == "" { 314 | log.Warnf("No New Relic license key... skipping stats reporting") 315 | } else { 316 | go processUploads(nrChan) 317 | } 318 | 319 | select {} 320 | } 321 | --------------------------------------------------------------------------------