ruby-druid 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +15 -0
- data/.gitignore +5 -0
- data/Gemfile +21 -0
- data/Guardfile +10 -0
- data/LICENSE +20 -0
- data/README.md +286 -0
- data/Rakefile +1 -0
- data/bin/dripl +40 -0
- data/dot_driplrc_example +12 -0
- data/lib/druid.rb +8 -0
- data/lib/druid/client.rb +95 -0
- data/lib/druid/console.rb +66 -0
- data/lib/druid/filter.rb +216 -0
- data/lib/druid/having.rb +53 -0
- data/lib/druid/post_aggregation.rb +111 -0
- data/lib/druid/query.rb +175 -0
- data/lib/druid/response_row.rb +32 -0
- data/lib/druid/zoo_handler.rb +129 -0
- data/ruby-druid.gemspec +19 -0
- data/spec/lib/client_spec.rb +69 -0
- data/spec/lib/query_spec.rb +377 -0
- data/spec/lib/zoo_handler_spec.rb +200 -0
- data/spec/spec_helper.rb +2 -0
- metadata +96 -0
checksums.yaml
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
---
|
2
|
+
!binary "U0hBMQ==":
|
3
|
+
metadata.gz: !binary |-
|
4
|
+
NjkxZmU4ODgzYmYwNzJhMTU1NDY4YjU1OGYzOTBhMmM2MzJjZDE2Ng==
|
5
|
+
data.tar.gz: !binary |-
|
6
|
+
ODQ3YWFlYThkNzQ0YWMwNjU3MTJjYjQ5Y2QzMjAxYTMyNTYwZjBiYQ==
|
7
|
+
!binary "U0hBNTEy":
|
8
|
+
metadata.gz: !binary |-
|
9
|
+
ZjVjOWJiZDEyNjA0ZDkwM2UzZTYyNDI5YTlkN2NkZjI5MmVjODBmNzg0Yjky
|
10
|
+
MGExM2JkOTM1OTliYTA3NmE3Y2VmOTk1ODhmZDI4NTUxYmMwOGQwYTZkZTRh
|
11
|
+
Njk1MzZhMTc5NmQ0OTQ4NmVjNjE4OWI0ZjE4M2M3Yzk4MDEwYmY=
|
12
|
+
data.tar.gz: !binary |-
|
13
|
+
MTkxZGFjYzAzNDk5NWZhY2FkM2E2ODYzNmY1MzkzZTE4M2U0MzFhMDViZWI0
|
14
|
+
YmNhOTZiNzlhZWFlYWI2ZDRiOWY4NGE2NmQ1Y2I3OTljNjIyNWEwNzczYzRl
|
15
|
+
MWZhZmQwZTA4OThiMjA4ODAyYjE0MDU0YzBjMzViY2I3ZTJjYzM=
|
data/.gitignore
ADDED
data/Gemfile
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
source 'https://rubygems.org'
|
2
|
+
|
3
|
+
gemspec
|
4
|
+
|
5
|
+
group :test, :development do
|
6
|
+
gem 'guard'
|
7
|
+
gem 'guard-bundler'
|
8
|
+
gem 'guard-rspec'
|
9
|
+
gem 'rb-fsevent'
|
10
|
+
gem 'rspec'
|
11
|
+
gem 'ruby_gntp'
|
12
|
+
gem 'webmock'
|
13
|
+
gem 'debugger'
|
14
|
+
end
|
15
|
+
|
16
|
+
group :console do
|
17
|
+
gem 'activesupport'
|
18
|
+
gem 'awesome_print'
|
19
|
+
gem 'ripl'
|
20
|
+
gem 'terminal-table'
|
21
|
+
end
|
data/Guardfile
ADDED
@@ -0,0 +1,10 @@
|
|
1
|
+
# More info at https://github.com/guard/guard#readme
|
2
|
+
guard :bundler do
|
3
|
+
watch('Gemfile')
|
4
|
+
end
|
5
|
+
|
6
|
+
guard :rspec, :cli => '--color --format nested' do
|
7
|
+
watch(%r{^spec/.+_spec\.rb$})
|
8
|
+
watch(%r{^(.+)\.rb$}) {|m| "spec/#{m[1]}_spec.rb" }
|
9
|
+
watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
|
10
|
+
end
|
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2013 madvertise Mobile Advertising GmbH
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be included
|
12
|
+
in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
17
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
18
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
19
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
20
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,286 @@
|
|
1
|
+
# ruby-druid
|
2
|
+
|
3
|
+
[](https://codeclimate.com/github/madvertise/ruby-druid)
|
4
|
+
|
5
|
+
A ruby client for [druid](https://github.com/madvertise/druid).
|
6
|
+
|
7
|
+
ruby-druid generates complete JSON queries by chaining methods.
|
8
|
+
The resulting JSON can be send directly to a druid server or handled seperatly.
|
9
|
+
|
10
|
+
## bin/dripl
|
11
|
+
|
12
|
+
ruby-druid now includes a repl:
|
13
|
+
|
14
|
+
```ruby
|
15
|
+
$ bin/dripl
|
16
|
+
>> metrics
|
17
|
+
[
|
18
|
+
[0] "actions"
|
19
|
+
]
|
20
|
+
|
21
|
+
>> dimensions
|
22
|
+
[
|
23
|
+
[0] "actions"
|
24
|
+
]
|
25
|
+
|
26
|
+
>> long_sum(:actions)
|
27
|
+
+---------+
|
28
|
+
| actions |
|
29
|
+
+---------+
|
30
|
+
| 98575 |
|
31
|
+
+---------+
|
32
|
+
|
33
|
+
>> long_sum(:actions)[-7.days].granularity(:day)
|
34
|
+
+-------------------------------+----------+
|
35
|
+
| timestamp | actions |
|
36
|
+
+-------------------------------+----------+
|
37
|
+
| 2013-03-28T00:00:00.000+01:00 | 93371 |
|
38
|
+
| 2013-03-29T00:00:00.000+01:00 | 448200 |
|
39
|
+
| 2013-03-30T00:00:00.000+01:00 | 117167 |
|
40
|
+
| 2013-03-31T00:00:00.000+01:00 | 828321 |
|
41
|
+
| 2013-04-01T00:00:00.000+02:00 | 261578 |
|
42
|
+
| 2013-04-02T00:00:00.000+02:00 | 05149 |
|
43
|
+
| 2013-04-03T00:00:00.000+02:00 | 27512 |
|
44
|
+
| 2013-04-04T00:00:00.000+02:00 | 18897 |
|
45
|
+
+-------------------------------+----------+
|
46
|
+
|
47
|
+
>> long_sum(:actions)[-7.days].granularity(:day).properties
|
48
|
+
{
|
49
|
+
:dataSource => "events",
|
50
|
+
:granularity => {
|
51
|
+
:type => "period",
|
52
|
+
:period => "P1D",
|
53
|
+
:timeZone => "Europe/Berlin"
|
54
|
+
},
|
55
|
+
:intervals => [
|
56
|
+
[0] "2013-03-28T00:00:00+01:00/2013-04-04T11:57:20+02:00"
|
57
|
+
],
|
58
|
+
:queryType => :groupBy,
|
59
|
+
:aggregations => [
|
60
|
+
[0] {
|
61
|
+
:type => "longSum",
|
62
|
+
:name => :actions,
|
63
|
+
:fieldName => :actions
|
64
|
+
}
|
65
|
+
]
|
66
|
+
}
|
67
|
+
```
|
68
|
+
|
69
|
+
## Getting started
|
70
|
+
|
71
|
+
In your Gemfile:
|
72
|
+
|
73
|
+
```ruby
|
74
|
+
gem 'ruby-druid'
|
75
|
+
```
|
76
|
+
|
77
|
+
In your code:
|
78
|
+
|
79
|
+
```ruby
|
80
|
+
require 'druid'
|
81
|
+
```
|
82
|
+
|
83
|
+
## Usage
|
84
|
+
|
85
|
+
```ruby
|
86
|
+
Druid::Client.new('zk1:2181,zk2:2181/druid').query('service/source')
|
87
|
+
```
|
88
|
+
|
89
|
+
returns a query object on which all other methods can be called to create a full and valid druid query.
|
90
|
+
|
91
|
+
A query object can be sent like this:
|
92
|
+
|
93
|
+
```ruby
|
94
|
+
Druid::Client.new('zk1:2181,zk2:2181/druid').query('service/source').send
|
95
|
+
#or
|
96
|
+
client = Druid::Client.new('zk1:2181,zk2:2181/druid')
|
97
|
+
query = Druid::Query.new('service/source')
|
98
|
+
client.send(query)
|
99
|
+
```
|
100
|
+
|
101
|
+
The `send` method returns the parsed response from the druid server as an array.
|
102
|
+
If the response is not empty it contains one `ResponseRow` object for each row.
|
103
|
+
The timestamp by can be received by a method with the same name (i.e. `row.timestamp`),
|
104
|
+
all row values by hashlike syntax (i.e. `row['dimension'])
|
105
|
+
|
106
|
+
### group_by
|
107
|
+
|
108
|
+
Sets the dimensions to group the data.
|
109
|
+
|
110
|
+
`queryType` is set automatically to `groupBy`.
|
111
|
+
|
112
|
+
|
113
|
+
```ruby
|
114
|
+
Druid::Query.new('service/source').group_by([:dimension1, :dimension2])
|
115
|
+
```
|
116
|
+
|
117
|
+
### long_sum
|
118
|
+
|
119
|
+
```ruby
|
120
|
+
Druid::Query.new('service/source').long_sum([:aggregate1, :aggregate2])
|
121
|
+
```
|
122
|
+
|
123
|
+
### postagg
|
124
|
+
|
125
|
+
A simple syntax for post aggregations with +,-,/,* can be used like:
|
126
|
+
|
127
|
+
```ruby
|
128
|
+
query = Druid::Query.new('service/source').long_sum([:aggregate1, :aggregate2])
|
129
|
+
|
130
|
+
query.postagg{(aggregate2 + aggregate2).as output_field_name}
|
131
|
+
```
|
132
|
+
|
133
|
+
Required fields for the postaggregation are fetched automatically by the library.
|
134
|
+
|
135
|
+
### interval
|
136
|
+
|
137
|
+
The interval for the query takes a string with date and time or objects that provide a `iso8601` method
|
138
|
+
|
139
|
+
```ruby
|
140
|
+
query = Druid::Query.new('service/source').long_sum(:aggregate1)
|
141
|
+
|
142
|
+
query.interval("2013-01-01T00", Time.now)
|
143
|
+
```
|
144
|
+
|
145
|
+
### granularity
|
146
|
+
|
147
|
+
granularity can be `:all`, `:none`, `:minute`, `:fifteen_minute`, `:thirthy_minute`, `:hour` or `:day`.
|
148
|
+
|
149
|
+
It can also be a period granularity as described in https://github.com/metamx/druid/wiki/Granularities.
|
150
|
+
|
151
|
+
The period `'day'` or `:day` will be interpreted as `'P1D'`.
|
152
|
+
|
153
|
+
If a period granularity is specifed, the (optional) second parameter is a time zone. It defaults
|
154
|
+
to the machines local time zone.
|
155
|
+
|
156
|
+
I.E:
|
157
|
+
```ruby
|
158
|
+
query = Druid::Query.new('service/source').long_sum(:aggregate1)
|
159
|
+
|
160
|
+
query.granularity(:day)
|
161
|
+
```
|
162
|
+
|
163
|
+
is (on my box) the same as
|
164
|
+
|
165
|
+
```ruby
|
166
|
+
query = Druid::Query.new('service/source').long_sum(:aggregate1)
|
167
|
+
|
168
|
+
query.granularity('P1D', 'Europe/Berlin')
|
169
|
+
```
|
170
|
+
|
171
|
+
## having (for metrics)
|
172
|
+
|
173
|
+
### having >
|
174
|
+
|
175
|
+
```ruby
|
176
|
+
Druid::Query.new('service/source').having{metric > 10}
|
177
|
+
```
|
178
|
+
|
179
|
+
### having <
|
180
|
+
|
181
|
+
```ruby
|
182
|
+
Druid::Query.new('service/source').having{metric < 10}
|
183
|
+
```
|
184
|
+
|
185
|
+
## filter (for dimensions)
|
186
|
+
|
187
|
+
Filters are set by the `filter` method. It takes a block or a hash as parameter.
|
188
|
+
|
189
|
+
Filters can be chained `filter{...}.filter{...}`
|
190
|
+
|
191
|
+
### filter == , eq
|
192
|
+
|
193
|
+
```ruby
|
194
|
+
Druid::Query.new('service/source').filter{dimension.eq 1}
|
195
|
+
|
196
|
+
#this is the same as
|
197
|
+
|
198
|
+
Druid::Query.new('service/source').filter{dimension == 1}
|
199
|
+
```
|
200
|
+
|
201
|
+
### filter != , neq
|
202
|
+
|
203
|
+
```ruby
|
204
|
+
Druid::Query.new('service/source').filter{dimension.neq 1}
|
205
|
+
|
206
|
+
#this is the same as
|
207
|
+
|
208
|
+
Druid::Query.new('service/source').filter{dimension != 1}
|
209
|
+
```
|
210
|
+
|
211
|
+
### filter and
|
212
|
+
|
213
|
+
a logical or than can combine all other filters
|
214
|
+
|
215
|
+
```ruby
|
216
|
+
Druid::Query.new('service/source').filter{dimension.neq 1 & dimension2.neq 2}
|
217
|
+
```
|
218
|
+
|
219
|
+
### filter or
|
220
|
+
|
221
|
+
a logical or than can combine all other filters
|
222
|
+
|
223
|
+
```ruby
|
224
|
+
Druid::Query.new('service/source').filter{dimension.neq 1 | dimension2.neq 2}
|
225
|
+
```
|
226
|
+
|
227
|
+
### filter not
|
228
|
+
|
229
|
+
a logical not than can negate all other filter
|
230
|
+
|
231
|
+
```ruby
|
232
|
+
Druid::Query.new('service/source').filter{!dimension.eq(1)}
|
233
|
+
```
|
234
|
+
|
235
|
+
### filter in
|
236
|
+
|
237
|
+
This filter creates a set of equals filters in an and filter.
|
238
|
+
|
239
|
+
```ruby
|
240
|
+
Druid::Query.new('service/source').filter{dimension.in(1,2,3)}
|
241
|
+
```
|
242
|
+
|
243
|
+
### filter with hash syntax
|
244
|
+
|
245
|
+
sometimes it can be useful to use a hash syntax for filtering
|
246
|
+
for example if you already get them from a list or parameterhash
|
247
|
+
|
248
|
+
```ruby
|
249
|
+
Druid::Query.new('service/source').filter{dimension => 1, dimension1 =>2, dimension2 => 3}
|
250
|
+
|
251
|
+
#this is the same as
|
252
|
+
|
253
|
+
Druid::Query.new('service/source').filter{dimension.eq(1) & dimension1.eq(2) & dimension2.eq(3)}
|
254
|
+
```
|
255
|
+
|
256
|
+
### filter >, <, >=, <=
|
257
|
+
|
258
|
+
```ruby
|
259
|
+
Druid::Query.new('service/source').filter{dimension >= 1}
|
260
|
+
```
|
261
|
+
|
262
|
+
### filter javascript
|
263
|
+
|
264
|
+
```ruby
|
265
|
+
Druid::Query.new('service/source').filter{a.javascript('dimension >= 1 && dimension < 5')}
|
266
|
+
|
267
|
+
#this also the same as
|
268
|
+
|
269
|
+
Druid::Query.new('service/source').filter{(dimension >= 1) & (dimension < 5)}
|
270
|
+
```
|
271
|
+
|
272
|
+
## Acknowledgements
|
273
|
+
|
274
|
+
Post aggregation expression parsing built with the help of [Squeel](https://github.com/ernie/squeel).
|
275
|
+
|
276
|
+
## Contributions
|
277
|
+
|
278
|
+
ruby-druid is developed by madvertise Mobile Advertising GmbH
|
279
|
+
|
280
|
+
You can support us on different ways:
|
281
|
+
|
282
|
+
* Use ruby-druid, and let us know if you encounter anything that's broken or missing.
|
283
|
+
A failing spec is great. A pull request with your fix is even better!
|
284
|
+
* Spread the word about ruby-druid on Twitter, Facebook, and elsewhere.
|
285
|
+
* Work with us at madvertise on awesome stuff like this.
|
286
|
+
[Read the job description](http://madvertise.com/software-developer-ruby-fm-berlin) and send a mail to careers@madvertise.com.
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
data/bin/dripl
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
$:.unshift(File.join(File.expand_path("../..", __FILE__), 'lib'))
|
5
|
+
|
6
|
+
$0 = "dripl"
|
7
|
+
|
8
|
+
def zookeeper(value)
|
9
|
+
@zk_uri = value
|
10
|
+
end
|
11
|
+
|
12
|
+
def uri(value)
|
13
|
+
puts "using 'uri' in the config is deprecated, use 'zookeeper' instead"
|
14
|
+
zookeeper value
|
15
|
+
end
|
16
|
+
|
17
|
+
def source(value)
|
18
|
+
@source = value
|
19
|
+
end
|
20
|
+
|
21
|
+
def options(value)
|
22
|
+
@options = value
|
23
|
+
end
|
24
|
+
|
25
|
+
begin
|
26
|
+
driplrc = File.read(File.join(File.expand_path("../..", __FILE__), '.driplrc'))
|
27
|
+
rescue
|
28
|
+
puts "You need to create a .driplrc, take a look at dot_driplrc_example"
|
29
|
+
exit 1
|
30
|
+
end
|
31
|
+
|
32
|
+
instance_eval(driplrc)
|
33
|
+
|
34
|
+
unless @zk_uri || (@options && @options[:static_setup])
|
35
|
+
puts "Your .driplrc is incomplete, please fix"
|
36
|
+
exit 1
|
37
|
+
end
|
38
|
+
|
39
|
+
require 'druid/console'
|
40
|
+
Druid::Console.new(@zk_uri, @source, @options)
|
data/dot_driplrc_example
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
## your zookeeper config. For static scenarios (i.e. ssh tunnels) see options
|
2
|
+
##
|
3
|
+
# zookeeper "localhost:2181/druid"
|
4
|
+
|
5
|
+
## using options, you can disable zookeeper lookup
|
6
|
+
## options[:static_setup], the key is the source name, the value is the brokers post uri
|
7
|
+
##
|
8
|
+
# options :static_setup => { 'example/events' => 'http://localhost:8080/druid/v2/' }
|
9
|
+
|
10
|
+
## dripl will default to use the first available data source. use this to override
|
11
|
+
##
|
12
|
+
# source "example/events"
|
data/lib/druid.rb
ADDED
data/lib/druid/client.rb
ADDED
@@ -0,0 +1,95 @@
|
|
1
|
+
module Druid
|
2
|
+
class Client
|
3
|
+
TIMEOUT = 2 * 60 * 1000
|
4
|
+
|
5
|
+
def initialize(zookeeper_uri, opts = nil)
|
6
|
+
opts ||= {}
|
7
|
+
|
8
|
+
if opts[:static_setup] && !opts[:fallback]
|
9
|
+
@static = opts[:static_setup]
|
10
|
+
else
|
11
|
+
@backup = opts[:static_setup] if opts[:fallback]
|
12
|
+
zookeeper_caching_management!(zookeeper_uri, opts)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def send(query)
|
17
|
+
uri = data_source_uri(query.source)
|
18
|
+
raise "data source #{query.source} (currently) not available" unless uri
|
19
|
+
|
20
|
+
req = Net::HTTP::Post.new(uri.path, initheader = {'Content-Type' =>'application/json'})
|
21
|
+
req.body = query.to_json
|
22
|
+
puts req.body
|
23
|
+
|
24
|
+
response = Net::HTTP.new(uri.host, uri.port).start do |http|
|
25
|
+
http.read_timeout = TIMEOUT
|
26
|
+
http.request(req)
|
27
|
+
end
|
28
|
+
|
29
|
+
if response.code == "200"
|
30
|
+
JSON.parse(response.body).map{ |row| ResponseRow.new(row) }
|
31
|
+
else
|
32
|
+
raise "Request failed: #{response.code}: #{response.body}"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def query(id, &block)
|
37
|
+
uri = data_source_uri(id)
|
38
|
+
raise "data source #{id} (currently) not available" unless uri
|
39
|
+
query = Query.new(id, self)
|
40
|
+
return query unless block
|
41
|
+
|
42
|
+
send query
|
43
|
+
end
|
44
|
+
|
45
|
+
def zookeeper_caching_management!(zookeeper_uri, opts)
|
46
|
+
@zk = ZooHandler.new(zookeeper_uri, opts)
|
47
|
+
|
48
|
+
unless opts[:zk_keepalive]
|
49
|
+
@cached_data_sources = @zk.data_sources unless @zk.nil?
|
50
|
+
|
51
|
+
@zk.close!
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def ds
|
56
|
+
@cached_data_sources || (@zk.data_sources unless @zk.nil?)
|
57
|
+
end
|
58
|
+
|
59
|
+
def data_sources
|
60
|
+
(ds.nil? ? @static : ds).keys
|
61
|
+
end
|
62
|
+
|
63
|
+
def data_source_uri(source)
|
64
|
+
uri = (ds.nil? ? @static : ds)[source]
|
65
|
+
begin
|
66
|
+
return URI(uri) if uri
|
67
|
+
rescue
|
68
|
+
return URI(@backup) if @backup
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def data_source(source)
|
73
|
+
uri = data_source_uri(source)
|
74
|
+
raise "data source #{source} (currently) not available" unless uri
|
75
|
+
|
76
|
+
meta_path = "#{uri.path}datasources/#{source.split('/').last}"
|
77
|
+
|
78
|
+
req = Net::HTTP::Get.new(meta_path)
|
79
|
+
|
80
|
+
response = Net::HTTP.new(uri.host, uri.port).start do |http|
|
81
|
+
http.read_timeout = TIMEOUT
|
82
|
+
http.request(req)
|
83
|
+
end
|
84
|
+
|
85
|
+
if response.code == "200"
|
86
|
+
meta = JSON.parse(response.body)
|
87
|
+
meta.define_singleton_method(:dimensions) { self['dimensions'] }
|
88
|
+
meta.define_singleton_method(:metrics) { self['metrics'] }
|
89
|
+
meta
|
90
|
+
else
|
91
|
+
raise "Request failed: #{response.code}: #{response.body}"
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|