Commit a20309d7 by Shavonn Brown Committed by GitHub

Feature: Parse user agent string in user auth token api response (#16… (#17504)

* Feature: Parse user agent string in user auth token api response (#16222)

* Adding UA Parser Go modules attempt (#16222)

* Bring user agent vals up per req

* fix tests

* doc update

* update to flatten, no maps

* update doc
parent 20f81539
......@@ -373,7 +373,11 @@ Content-Type: application/json
"id": 361,
"isActive": false,
"clientIp": "127.0.0.1",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36",
"browser": "Chrome",
"browserVersion": "72.0",
"os": "Linux",
"osVersion": "",
"device": "Other",
"createdAt": "2019-03-05T21:22:54+01:00",
"seenAt": "2019-03-06T19:41:06+01:00"
},
......@@ -381,7 +385,11 @@ Content-Type: application/json
"id": 364,
"isActive": false,
"clientIp": "127.0.0.1",
"userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1",
"browser": "Mobile Safari",
"browserVersion": "11.0",
"os": "iOS",
"osVersion": "11.0",
"device": "iPhone",
"createdAt": "2019-03-06T19:41:19+01:00",
"seenAt": "2019-03-06T19:41:21+01:00"
}
......
......@@ -505,7 +505,11 @@ Content-Type: application/json
"id": 361,
"isActive": true,
"clientIp": "127.0.0.1",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36",
"browser": "Chrome",
"browserVersion": "72.0",
"os": "Linux",
"osVersion": "",
"device": "Other",
"createdAt": "2019-03-05T21:22:54+01:00",
"seenAt": "2019-03-06T19:41:06+01:00"
},
......@@ -513,7 +517,11 @@ Content-Type: application/json
"id": 364,
"isActive": false,
"clientIp": "127.0.0.1",
"userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1",
"browser": "Mobile Safari",
"browserVersion": "11.0",
"os": "iOS",
"osVersion": "11.0",
"device": "iPhone",
"createdAt": "2019-03-06T19:41:19+01:00",
"seenAt": "2019-03-06T19:41:21+01:00"
}
......
......@@ -58,6 +58,7 @@ require (
github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a
github.com/stretchr/testify v1.3.0
github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf
github.com/ua-parser/uap-go v0.0.0-20190303233514-1004ccd816b3
github.com/uber-go/atomic v1.3.2 // indirect
github.com/uber/jaeger-client-go v2.16.0+incompatible
github.com/uber/jaeger-lib v2.0.0+incompatible // indirect
......
......@@ -191,6 +191,8 @@ github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf h1:Z2X3Os7oRzpdJ75iPqWZc0HeJWFYNCvKsfpQwFpRNTA=
github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0=
github.com/ua-parser/uap-go v0.0.0-20190303233514-1004ccd816b3 h1:E7xa7Zur8hLPvw+03gAeQ9esrglfV389j2PcwhiGf/I=
github.com/ua-parser/uap-go v0.0.0-20190303233514-1004ccd816b3/go.mod h1:OBcG9bn7sHtXgarhUEb3OfCnNsgtGnkVf41ilSZ3K3E=
github.com/uber-go/atomic v1.3.2 h1:Azu9lPBWRNKzYXSIwRfgRuDuS0YKsK4NFhiQv98gkxo=
github.com/uber-go/atomic v1.3.2/go.mod h1:/Ct5t2lcmbJ4OSe/waGBoaVvVqtO0bmtfVNex1PFV8g=
github.com/uber/jaeger-client-go v2.16.0+incompatible h1:Q2Pp6v3QYiocMxomCaJuwQGFt7E53bPYqEgug/AoBtY=
......
......@@ -3,10 +3,14 @@ package dtos
import "time"
type UserToken struct {
Id int64 `json:"id"`
IsActive bool `json:"isActive"`
ClientIp string `json:"clientIp"`
UserAgent string `json:"userAgent"`
CreatedAt time.Time `json:"createdAt"`
SeenAt time.Time `json:"seenAt"`
Id int64 `json:"id"`
IsActive bool `json:"isActive"`
ClientIp string `json:"clientIp"`
Device string `json:"device"`
OperatingSystem string `json:"os"`
OperatingSystemVersion string `json:"osVersion"`
Browser string `json:"browser"`
BrowserVersion string `json:"browserVersion"`
CreatedAt time.Time `json:"createdAt"`
SeenAt time.Time `json:"seenAt"`
}
......@@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/util"
"github.com/ua-parser/uap-go/uaparser"
)
// GET /api/user/auth-tokens
......@@ -62,13 +63,38 @@ func (server *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID
isActive = true
}
parser := uaparser.NewFromSaved()
client := parser.Parse(token.UserAgent)
osVersion := ""
if client.Os.Major != "" {
osVersion = client.Os.Major
if client.Os.Minor != "" {
osVersion = osVersion + "." + client.Os.Minor
}
}
browserVersion := ""
if client.UserAgent.Major != "" {
browserVersion = client.UserAgent.Major
if client.UserAgent.Minor != "" {
browserVersion = browserVersion + "." + client.UserAgent.Minor
}
}
result = append(result, &dtos.UserToken{
Id: token.Id,
IsActive: isActive,
ClientIp: token.ClientIp,
UserAgent: token.UserAgent,
CreatedAt: time.Unix(token.CreatedAt, 0),
SeenAt: time.Unix(token.SeenAt, 0),
Id: token.Id,
IsActive: isActive,
ClientIp: token.ClientIp,
Device: client.Device.ToString(),
OperatingSystem: client.Os.Family,
OperatingSystemVersion: osVersion,
Browser: client.UserAgent.Family,
BrowserVersion: browserVersion,
CreatedAt: time.Unix(token.CreatedAt, 0),
SeenAt: time.Unix(token.SeenAt, 0),
})
}
......
......@@ -140,17 +140,27 @@ func TestUserTokenApiEndpoint(t *testing.T) {
So(resultOne.Get("id").MustInt64(), ShouldEqual, tokens[0].Id)
So(resultOne.Get("isActive").MustBool(), ShouldBeTrue)
So(resultOne.Get("clientIp").MustString(), ShouldEqual, "127.0.0.1")
So(resultOne.Get("userAgent").MustString(), ShouldEqual, "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36")
So(resultOne.Get("createdAt").MustString(), ShouldEqual, time.Unix(tokens[0].CreatedAt, 0).Format(time.RFC3339))
So(resultOne.Get("seenAt").MustString(), ShouldEqual, time.Unix(tokens[0].SeenAt, 0).Format(time.RFC3339))
So(resultOne.Get("device").MustString(), ShouldEqual, "Other")
So(resultOne.Get("browser").MustString(), ShouldEqual, "Chrome")
So(resultOne.Get("browserVersion").MustString(), ShouldEqual, "72.0")
So(resultOne.Get("os").MustString(), ShouldEqual, "Linux")
So(resultOne.Get("osVersion").MustString(), ShouldEqual, "")
resultTwo := result.GetIndex(1)
So(resultTwo.Get("id").MustInt64(), ShouldEqual, tokens[1].Id)
So(resultTwo.Get("isActive").MustBool(), ShouldBeFalse)
So(resultTwo.Get("clientIp").MustString(), ShouldEqual, "127.0.0.2")
So(resultTwo.Get("userAgent").MustString(), ShouldEqual, "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1")
So(resultTwo.Get("createdAt").MustString(), ShouldEqual, time.Unix(tokens[1].CreatedAt, 0).Format(time.RFC3339))
So(resultTwo.Get("seenAt").MustString(), ShouldEqual, time.Unix(tokens[1].SeenAt, 0).Format(time.RFC3339))
So(resultTwo.Get("device").MustString(), ShouldEqual, "iPhone")
So(resultTwo.Get("browser").MustString(), ShouldEqual, "Mobile Safari")
So(resultTwo.Get("browserVersion").MustString(), ShouldEqual, "11.0")
So(resultTwo.Get("os").MustString(), ShouldEqual, "iOS")
So(resultTwo.Get("osVersion").MustString(), ShouldEqual, "11.0")
})
})
}
......
Apache License, Version 2.0
===========================
Copyright 2009 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
\ No newline at end of file
The MIT License (MIT)
Copyright (c) 2013 Yihuan Zhou
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
package uaparser
import "strings"
type Device struct {
Family string
Brand string
Model string
}
func (parser *deviceParser) Match(line string, dvc *Device) {
matches := parser.Reg.FindStringSubmatchIndex(line)
if len(matches) == 0 {
return
}
dvc.Family = string(parser.Reg.ExpandString(nil, parser.DeviceReplacement, line, matches))
dvc.Family = strings.TrimSpace(dvc.Family)
dvc.Brand = string(parser.Reg.ExpandString(nil, parser.BrandReplacement, line, matches))
dvc.Brand = strings.TrimSpace(dvc.Brand)
dvc.Model = string(parser.Reg.ExpandString(nil, parser.ModelReplacement, line, matches))
dvc.Model = strings.TrimSpace(dvc.Model)
}
func (dvc *Device) ToString() string {
return dvc.Family
}
package uaparser
type Os struct {
Family string
Major string
Minor string
Patch string
PatchMinor string `yaml:"patch_minor"`
}
func (parser *osParser) Match(line string, os *Os) {
matches := parser.Reg.FindStringSubmatchIndex(line)
if len(matches) > 0 {
os.Family = string(parser.Reg.ExpandString(nil, parser.OSReplacement, line, matches))
os.Major = string(parser.Reg.ExpandString(nil, parser.V1Replacement, line, matches))
os.Minor = string(parser.Reg.ExpandString(nil, parser.V2Replacement, line, matches))
os.Patch = string(parser.Reg.ExpandString(nil, parser.V3Replacement, line, matches))
os.PatchMinor = string(parser.Reg.ExpandString(nil, parser.V4Replacement, line, matches))
}
}
func (os *Os) ToString() string {
var str string
if os.Family != "" {
str += os.Family
}
version := os.ToVersionString()
if version != "" {
str += " " + version
}
return str
}
func (os *Os) ToVersionString() string {
var version string
if os.Major != "" {
version += os.Major
}
if os.Minor != "" {
version += "." + os.Minor
}
if os.Patch != "" {
version += "." + os.Patch
}
if os.PatchMinor != "" {
version += "." + os.PatchMinor
}
return version
}
package uaparser
import (
"fmt"
"io/ioutil"
"regexp"
"sync"
"sync/atomic"
"sort"
"time"
"gopkg.in/yaml.v2"
)
type RegexesDefinitions struct {
UA []*uaParser `yaml:"user_agent_parsers"`
OS []*osParser `yaml:"os_parsers"`
Device []*deviceParser `yaml:"device_parsers"`
sync.RWMutex
}
type UserAgentSorter []*uaParser
func (a UserAgentSorter) Len() int { return len(a) }
func (a UserAgentSorter) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a UserAgentSorter) Less(i, j int) bool { return atomic.LoadUint64(&a[i].MatchesCount) > atomic.LoadUint64(&a[j].MatchesCount) }
type uaParser struct {
Reg *regexp.Regexp
Expr string `yaml:"regex"`
Flags string `yaml:"regex_flag"`
FamilyReplacement string `yaml:"family_replacement"`
V1Replacement string `yaml:"v1_replacement"`
V2Replacement string `yaml:"v2_replacement"`
V3Replacement string `yaml:"v3_replacement"`
MatchesCount uint64
}
func (ua *uaParser) setDefaults() {
if ua.FamilyReplacement == "" {
ua.FamilyReplacement = "$1"
}
if ua.V1Replacement == "" {
ua.V1Replacement = "$2"
}
if ua.V2Replacement == "" {
ua.V2Replacement = "$3"
}
if ua.V3Replacement == "" {
ua.V3Replacement = "$4"
}
}
type OsSorter []*osParser
func (a OsSorter) Len() int { return len(a) }
func (a OsSorter) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a OsSorter) Less(i, j int) bool { return atomic.LoadUint64(&a[i].MatchesCount) > atomic.LoadUint64(&a[j].MatchesCount) }
type osParser struct {
Reg *regexp.Regexp
Expr string `yaml:"regex"`
Flags string `yaml:"regex_flag"`
OSReplacement string `yaml:"os_replacement"`
V1Replacement string `yaml:"os_v1_replacement"`
V2Replacement string `yaml:"os_v2_replacement"`
V3Replacement string `yaml:"os_v3_replacement"`
V4Replacement string `yaml:"os_v4_replacement"`
MatchesCount uint64
}
func (os *osParser) setDefaults() {
if os.OSReplacement == "" {
os.OSReplacement = "$1"
}
if os.V1Replacement == "" {
os.V1Replacement = "$2"
}
if os.V2Replacement == "" {
os.V2Replacement = "$3"
}
if os.V3Replacement == "" {
os.V3Replacement = "$4"
}
if os.V4Replacement == "" {
os.V4Replacement = "$5"
}
}
type DeviceSorter []*deviceParser
func (a DeviceSorter) Len() int { return len(a) }
func (a DeviceSorter) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a DeviceSorter) Less(i, j int) bool { return atomic.LoadUint64(&a[i].MatchesCount) > atomic.LoadUint64(&a[j].MatchesCount) }
type deviceParser struct {
Reg *regexp.Regexp
Expr string `yaml:"regex"`
Flags string `yaml:"regex_flag"`
DeviceReplacement string `yaml:"device_replacement"`
BrandReplacement string `yaml:"brand_replacement"`
ModelReplacement string `yaml:"model_replacement"`
MatchesCount uint64
}
func (device *deviceParser) setDefaults() {
if device.DeviceReplacement == "" {
device.DeviceReplacement = "$1"
}
if device.ModelReplacement == "" {
device.ModelReplacement = "$1"
}
}
type Client struct {
UserAgent *UserAgent
Os *Os
Device *Device
}
type Parser struct {
RegexesDefinitions
UserAgentMisses uint64
OsMisses uint64
DeviceMisses uint64
Mode int
UseSort bool
debugMode bool
}
const (
EOsLookUpMode = 1 /* 00000001 */
EUserAgentLookUpMode = 2 /* 00000010 */
EDeviceLookUpMode = 4 /* 00000100 */
cMinMissesTreshold = 100000
cDefaultMissesTreshold = 500000
cDefaultMatchIdxNotOk = 20
cDefaultSortOption = false
)
var (
missesTreshold = uint64(500000)
matchIdxNotOk = 20
)
func (parser *Parser) mustCompile() { // until we can use yaml.UnmarshalYAML with embedded pointer struct
for _, p := range parser.UA {
p.Reg = compileRegex(p.Flags, p.Expr)
p.setDefaults()
}
for _, p := range parser.OS {
p.Reg = compileRegex(p.Flags, p.Expr)
p.setDefaults()
}
for _, p := range parser.Device {
p.Reg = compileRegex(p.Flags, p.Expr)
p.setDefaults()
}
}
func NewWithOptions(regexFile string, mode, treshold, topCnt int, useSort, debugMode bool) (*Parser, error) {
data, err := ioutil.ReadFile(regexFile)
if nil != err {
return nil, err
}
if topCnt >= 0 {
matchIdxNotOk = topCnt
}
if treshold > cMinMissesTreshold {
missesTreshold = uint64(treshold)
}
parser, err := NewFromBytes(data)
if err != nil {
return nil, err
}
parser.Mode = mode
parser.UseSort = useSort
parser.debugMode = debugMode
return parser, nil
}
func New(regexFile string) (*Parser, error) {
data, err := ioutil.ReadFile(regexFile)
if nil != err {
return nil, err
}
matchIdxNotOk = cDefaultMatchIdxNotOk
missesTreshold = cDefaultMissesTreshold
parser, err := NewFromBytes(data)
if err != nil {
return nil, err
}
return parser, nil
}
func NewFromSaved() *Parser {
parser, err := NewFromBytes(definitionYaml)
if err != nil {
// if the YAML is malformed, it's a programmatic error inside what
// we've statically-compiled in our binary. Panic!
panic(err.Error())
}
return parser
}
func NewFromBytes(data []byte) (*Parser, error) {
var definitions RegexesDefinitions
if err := yaml.Unmarshal(data, &definitions); err != nil {
return nil, err
}
parser := &Parser{definitions, 0, 0, 0, (EOsLookUpMode|EUserAgentLookUpMode|EDeviceLookUpMode), false, false}
parser.mustCompile()
return parser, nil
}
func (parser *Parser) Parse(line string) *Client {
cli := new(Client)
var wg sync.WaitGroup
if EUserAgentLookUpMode & parser.Mode == EUserAgentLookUpMode {
wg.Add(1)
go func() {
defer wg.Done()
parser.RLock()
cli.UserAgent = parser.ParseUserAgent(line)
parser.RUnlock()
}()
}
if EOsLookUpMode & parser.Mode == EOsLookUpMode {
wg.Add(1)
go func() {
defer wg.Done()
parser.RLock()
cli.Os = parser.ParseOs(line)
parser.RUnlock()
}()
}
if EDeviceLookUpMode & parser.Mode == EDeviceLookUpMode {
wg.Add(1)
go func() {
defer wg.Done()
parser.RLock()
cli.Device = parser.ParseDevice(line)
parser.RUnlock()
}()
}
wg.Wait()
if parser.UseSort == true {
checkAndSort(parser)
}
return cli
}
func (parser *Parser) ParseUserAgent(line string) *UserAgent {
ua := new(UserAgent)
foundIdx := -1
found := false
for i, uaPattern := range parser.UA {
uaPattern.Match(line, ua)
if len(ua.Family) > 0 {
found = true
foundIdx = i
atomic.AddUint64(&uaPattern.MatchesCount, 1)
break
}
}
if !found {
ua.Family = "Other"
}
if(foundIdx > matchIdxNotOk) {
atomic.AddUint64(&parser.UserAgentMisses, 1)
}
return ua
}
func (parser *Parser) ParseOs(line string) *Os {
os := new(Os)
foundIdx := -1
found := false
for i, osPattern := range parser.OS {
osPattern.Match(line, os)
if len(os.Family) > 0 {
found = true
foundIdx = i
atomic.AddUint64(&osPattern.MatchesCount, 1)
break
}
}
if !found {
os.Family = "Other"
}
if(foundIdx > matchIdxNotOk) {
atomic.AddUint64(&parser.OsMisses, 1)
}
return os
}
func (parser *Parser) ParseDevice(line string) *Device {
dvc := new(Device)
foundIdx := -1
found := false
for i, dvcPattern := range parser.Device {
dvcPattern.Match(line, dvc)
if len(dvc.Family) > 0 {
found = true
foundIdx = i
atomic.AddUint64(&dvcPattern.MatchesCount, 1)
break
}
}
if !found {
dvc.Family = "Other"
}
if(foundIdx > matchIdxNotOk) {
atomic.AddUint64(&parser.DeviceMisses, 1)
}
return dvc
}
func checkAndSort(parser *Parser) {
parser.Lock()
if(atomic.LoadUint64(&parser.UserAgentMisses) >= missesTreshold) {
if parser.debugMode {
fmt.Printf("%s\tSorting UserAgents slice\n", time.Now());
}
parser.UserAgentMisses = 0
sort.Sort(UserAgentSorter(parser.UA));
}
parser.Unlock()
parser.Lock()
if(atomic.LoadUint64(&parser.OsMisses) >= missesTreshold) {
if parser.debugMode {
fmt.Printf("%s\tSorting OS slice\n", time.Now());
}
parser.OsMisses = 0
sort.Sort(OsSorter(parser.OS));
}
parser.Unlock()
parser.Lock()
if(atomic.LoadUint64(&parser.DeviceMisses) >= missesTreshold) {
if parser.debugMode {
fmt.Printf("%s\tSorting Device slice\n", time.Now());
}
parser.DeviceMisses = 0
sort.Sort(DeviceSorter(parser.Device));
}
parser.Unlock()
}
func compileRegex(flags, expr string) *regexp.Regexp {
if flags == "" {
return regexp.MustCompile(expr)
} else {
return regexp.MustCompile(fmt.Sprintf("(?%s)%s", flags, expr))
}
}
package uaparser
type UserAgent struct {
Family string
Major string
Minor string
Patch string
}
func (parser *uaParser) Match(line string, ua *UserAgent) {
matches := parser.Reg.FindStringSubmatchIndex(line)
if len(matches) > 0 {
ua.Family = string(parser.Reg.ExpandString(nil, parser.FamilyReplacement, line, matches))
ua.Major = string(parser.Reg.ExpandString(nil, parser.V1Replacement, line, matches))
ua.Minor = string(parser.Reg.ExpandString(nil, parser.V2Replacement, line, matches))
ua.Patch = string(parser.Reg.ExpandString(nil, parser.V3Replacement, line, matches))
}
}
func (ua *UserAgent) ToString() string {
var str string
if ua.Family != "" {
str += ua.Family
}
version := ua.ToVersionString()
if version != "" {
str += " " + version
}
return str
}
func (ua *UserAgent) ToVersionString() string {
var version string
if ua.Major != "" {
version += ua.Major
}
if ua.Minor != "" {
version += "." + ua.Minor
}
if ua.Patch != "" {
version += "." + ua.Patch
}
return version
}
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -192,6 +192,8 @@ github.com/smartystreets/goconvey/convey/gotest
github.com/stretchr/testify/assert
# github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf
github.com/teris-io/shortid
# github.com/ua-parser/uap-go v0.0.0-20190303233514-1004ccd816b3
github.com/ua-parser/uap-go/uaparser
# github.com/uber/jaeger-client-go v2.16.0+incompatible
github.com/uber/jaeger-client-go/config
github.com/uber/jaeger-client-go/zipkin
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment