signalfx 1.0.2 → 2.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: b1e95b59377f963bd7d58da66522048b421077cd
4
- data.tar.gz: 300ab136c02605ccdf4c984be4d1d8978711868e
3
+ metadata.gz: 65d54216c0f3f8e9bac2bed755772ac96dc3b415
4
+ data.tar.gz: e90eeddd906dfe039ef213c5d605e4c29afca0da
5
5
  SHA512:
6
- metadata.gz: 146a8cdb0ff97ef3027f4a545051af579860a8a46cee893186f61d6e539774537fa3031317deb7f0127747ce4ce3f915bccd5149435636278d0f07d6113cccb3
7
- data.tar.gz: d45317cab75adabb588f0b67c5048007e083ac8bc557fc5827b153c55f33dd535a40e37409c9ece1b553d4078ec30b9d8ed7d5d26e0a6adbb7d3a6e0adbdb685
6
+ metadata.gz: 469077ec78a1414d3a44c6f8482a90d8e78ba2cf27a4a16d1708c87999baf7bf24bd761d819ca2a4f6a9d05b70c68e9f73302f509da9c50b130f16bb321f16f1
7
+ data.tar.gz: 183aa859b752dab6739d5a4d7e1668e1fa39b0c486197ab57c233ce159640b1f3d329653d9d3cd7d4a1be037e62e1d5f68b88ec8611962aff1efa18eb23aee97
data/.travis.yml CHANGED
@@ -1,5 +1,6 @@
1
1
  sudo: required
2
2
  language: ruby
3
+ dist: trusty
3
4
 
4
5
  rvm:
5
6
  - 2.2.3
data/Gemfile.lock CHANGED
@@ -1,44 +1,58 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- signalfx (1.0.1)
4
+ signalfx (2.0.1)
5
5
  protobuf (~> 3.5.1, >= 3.5.1)
6
6
  rest-client (~> 2.0)
7
+ websocket-client-simple (~> 0.3.0)
7
8
 
8
9
  GEM
9
10
  remote: https://rubygems.org/
10
11
  specs:
11
- activesupport (5.0.0.1)
12
+ activesupport (5.1.4)
12
13
  concurrent-ruby (~> 1.0, >= 1.0.2)
13
14
  i18n (~> 0.7)
14
15
  minitest (~> 5.1)
15
16
  tzinfo (~> 1.1)
16
17
  addressable (2.4.0)
17
- concurrent-ruby (1.0.2)
18
+ coderay (1.1.1)
19
+ concurrent-ruby (1.0.5)
18
20
  crack (0.4.3)
19
21
  safe_yaml (~> 1.0.0)
22
+ daemons (1.2.4)
20
23
  diff-lcs (1.2.5)
21
24
  docile (1.1.5)
22
- domain_name (0.5.20160826)
25
+ domain_name (0.5.20170404)
23
26
  unf (>= 0.0.5, < 1.0.0)
27
+ event_emitter (0.2.6)
28
+ eventmachine (1.2.5)
29
+ faye-websocket (0.10.7)
30
+ eventmachine (>= 0.12.0)
31
+ websocket-driver (>= 0.5.1)
24
32
  hashdiff (0.3.0)
25
- http-cookie (1.0.2)
33
+ http-cookie (1.0.3)
26
34
  domain_name (~> 0.5)
27
- i18n (0.7.0)
35
+ i18n (0.8.6)
28
36
  json (1.8.3)
37
+ method_source (0.8.2)
29
38
  middleware (0.1.0)
30
39
  mime-types (3.1)
31
40
  mime-types-data (~> 3.2015)
32
41
  mime-types-data (3.2016.0521)
33
- minitest (5.9.0)
42
+ minitest (5.10.3)
34
43
  netrc (0.11.0)
35
44
  protobuf (3.5.5)
36
45
  activesupport (>= 3.2)
37
46
  middleware
38
47
  thor
39
48
  thread_safe
49
+ pry (0.10.4)
50
+ coderay (~> 1.1.0)
51
+ method_source (~> 0.8.1)
52
+ slop (~> 3.4)
53
+ rack (2.0.3)
40
54
  rake (10.4.2)
41
- rest-client (2.0.0)
55
+ rest-client (2.0.2)
42
56
  http-cookie (>= 1.0.2, < 2.0)
43
57
  mime-types (>= 1.16, < 4.0)
44
58
  netrc (~> 0.8)
@@ -61,28 +75,43 @@ GEM
61
75
  json (~> 1.8)
62
76
  simplecov-html (~> 0.10.0)
63
77
  simplecov-html (0.10.0)
64
- thor (0.19.1)
65
- thread_safe (0.3.5)
66
- tzinfo (1.2.2)
78
+ slop (3.6.0)
79
+ thin (1.7.2)
80
+ daemons (~> 1.0, >= 1.0.9)
81
+ eventmachine (~> 1.0, >= 1.0.4)
82
+ rack (>= 1, < 3)
83
+ thor (0.20.0)
84
+ thread_safe (0.3.6)
85
+ tzinfo (1.2.3)
67
86
  thread_safe (~> 0.1)
68
87
  unf (0.1.4)
69
88
  unf_ext
70
- unf_ext (0.0.7.2)
89
+ unf_ext (0.0.7.4)
71
90
  webmock (2.1.0)
72
91
  addressable (>= 2.3.6)
73
92
  crack (>= 0.3.2)
74
93
  hashdiff
94
+ websocket (1.2.4)
95
+ websocket-client-simple (0.3.0)
96
+ event_emitter
97
+ websocket
98
+ websocket-driver (0.6.5)
99
+ websocket-extensions (>= 0.1.0)
100
+ websocket-extensions (0.1.2)
75
101
 
76
102
  PLATFORMS
77
103
  ruby
78
104
 
79
105
  DEPENDENCIES
80
106
  bundler (~> 1.10)
107
+ faye-websocket (~> 0.10.7)
108
+ pry
81
109
  rake (~> 10.0)
82
110
  rspec (~> 3.3)
83
111
  signalfx!
84
112
  simplecov
113
+ thin (~> 1.7)
85
114
  webmock (~> 2.1)
86
115
 
87
116
  BUNDLED WITH
88
- 1.13.1
117
+ 1.15.4
data/README.md CHANGED
@@ -155,6 +155,26 @@ client.send_event(
155
155
  See `examples/generic_usecase.rb` for a complete code example for
156
156
  sending events.
157
157
 
158
+ ### SignalFlow
159
+
160
+ You can run SignalFlow computations as well. This library supports all of the
161
+ functionality described in our [API docs for
162
+ SignalFlow](https://developers.signalfx.com/reference#signalflowconnect). Right
163
+ now, the only supported transport mechanism is WebSockets.
164
+
165
+ To create a new SignalFlow client instance from an existing SignalFx client:
166
+
167
+ ```ruby
168
+ signalflow = client.signalflow()
169
+ ```
170
+
171
+ For the full API see [the RubyDocs for
172
+ the SignalFlow
173
+ client](http://www.rubydoc.info/github/signalfx/signalfx-ruby/master/SignalFlowClient/)
174
+ (the `signalflow` var above).
175
+
176
+ There is also [a demo script](./examples/signalflow.rb) that shows basic usage.
177
+
158
178
  ## License
159
179
 
160
180
  Apache Software License v2. Copyright © 2015-2016
@@ -0,0 +1,32 @@
1
+ $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
2
+ require './lib/signalfx'
3
+
4
+ token = ARGV[0] # Your SignalFx API access token
5
+ if token.nil? || token.empty?
6
+ puts '
7
+ SignalFx API access token not defined. Please specify token in command line.
8
+ $ ./signalflow.rb YOUR_TOKEN
9
+
10
+ '
11
+ exit 0
12
+ end
13
+
14
+
15
+ #create client instance with SignalFx API access token
16
+ client = SignalFx.new(token, enable_aws_unique_id: false, timeout: 3000)
17
+
18
+ puts 'SignalFlow demo:'
19
+ puts
20
+
21
+ signalflow = client.signalflow()
22
+
23
+ signalflow.execute("data('cpu.utilization').publish()").each_message do |msg, comp|
24
+ case msg[:type]
25
+ when "data"
26
+ puts "#{'Host'.center(40, ' ')} | cpu.utilization"
27
+ msg[:data].each do |tsid,value|
28
+ puts "#{comp.metadata[tsid][:host][0..40].center(40, ' ')} | #{value}"
29
+ end
30
+ end
31
+ puts ""
32
+ end
data/lib/signalfx/conf.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  module RbConfig
4
4
  # Default Parameters
5
5
  DEFAULT_INGEST_ENDPOINT = 'https://ingest.signalfx.com'
6
+ DEFAULT_API_ENDPOINT = 'https://api.signalfx.com'
7
+ DEFAULT_STREAM_ENDPOINT = 'wss://stream.signalfx.com'
6
8
  DEFAULT_BATCH_SIZE = 300 # Will wait for this many requests before posting
7
9
  DEFAULT_TIMEOUT = 1
8
10
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative './version'
4
4
  require_relative './conf'
5
+ require_relative './signalflow/client'
5
6
 
6
7
  require 'net/http'
7
8
  require 'uri'
@@ -30,12 +31,16 @@ class SignalFxClient
30
31
  def initialize(api_token,
31
32
  enable_aws_unique_id: false,
32
33
  ingest_endpoint: RbConfig::DEFAULT_INGEST_ENDPOINT,
34
+ api_endpoint: RbConfig::DEFAULT_API_ENDPOINT,
35
+ stream_endpoint: RbConfig::DEFAULT_STREAM_ENDPOINT,
33
36
  timeout: RbConfig::DEFAULT_TIMEOUT,
34
37
  batch_size: RbConfig::DEFAULT_BATCH_SIZE,
35
38
  user_agents: [])
36
39
 
37
40
  @api_token = api_token
38
41
  @ingest_endpoint = ingest_endpoint
42
+ @api_endpoint = api_endpoint
43
+ @stream_endpoint = stream_endpoint
39
44
  @timeout = timeout
40
45
  @batch_size = batch_size
41
46
  @user_agents = user_agents
@@ -161,6 +166,15 @@ class SignalFxClient
161
166
  post(build_event(data), @ingest_endpoint, EVENT_ENDPOINT_SUFFIX)
162
167
  end
163
168
 
169
+ # Create a new SignalFlow client. A single client can execute multiple
170
+ # computations that will be multiplexed over the same WebSocket connection.
171
+ #
172
+ # @return [SignalFlowClient] a newly instantiated client, configured with the
173
+ # api token and endpoints from this class
174
+ def signalflow
175
+ SignalFlowClient.new(@api_token, @api_endpoint, @stream_endpoint)
176
+ end
177
+
164
178
  protected
165
179
 
166
180
  def get_queue
@@ -0,0 +1,62 @@
1
+ # Copyright (C) 2017 SignalFx, Inc. All rights reserved.
2
+
3
+ require 'json'
4
+ require 'zlib'
5
+
6
+ # Converts binary WebSocket messages into a hash
7
+ module BinaryMessageParser
8
+ # data should be a raw string
9
+ def parse(data)
10
+ # See https://developers.signalfx.com/v2/reference#section-binary-encoding-of-websocket-messages
11
+ version, message_type, flags, _, channel, payload = data.unpack("CCb8CZ16a*")
12
+ compressed = flags[0] == "1"
13
+ is_json = flags[1] == "1"
14
+
15
+ if version != 1
16
+ raise "Unsupported SignalFlow version #{version}"
17
+ end
18
+
19
+ if compressed
20
+ payload = Zlib::Inflate.new(16+Zlib::MAX_WBITS).inflate(payload)
21
+ end
22
+
23
+ raise "Unknown binary message type #{message_type}" if !is_json && message_type != 5
24
+
25
+ message = is_json ?
26
+ JSON.parse(payload, {:symbolize_names => true}) :
27
+ parse_data_payload(payload)
28
+
29
+ message.merge({:channel => channel})
30
+ end
31
+ module_function :parse
32
+
33
+ def parse_data_payload(payload)
34
+ # See https://developers.signalfx.com/v2/reference#section-binary-encoding-used-for-the-websocket
35
+ timestamp, element_count, tuples_raw = payload.unpack("Q>L>a*")
36
+ data_hash = (0..element_count-1).map do |i|
37
+ type, tsid, value_raw = tuples_raw[i*17..i*17+16].unpack("CQ>a8")
38
+
39
+ value = case type
40
+ when 1 # long
41
+ value_raw.unpack("q>")
42
+ when 2 # double
43
+ value_raw.unpack("G")
44
+ when 3 # int (32 bit)
45
+ value_raw.unpack("l>")
46
+ end
47
+
48
+ [
49
+ Base64.urlsafe_encode64([tsid].pack("Q>")).gsub("=", ""),
50
+ value[0],
51
+ ]
52
+ end.to_h
53
+
54
+ {
55
+ :type => "data",
56
+ :logicalTimestampMs => timestamp,
57
+ :logicalTimestamp => Time.at(timestamp / 1000.0),
58
+ :data => data_hash,
59
+ }
60
+ end
61
+ module_function :parse_data_payload
62
+ end
@@ -0,0 +1,80 @@
1
+ # Copyright (C) 2017 SignalFx, Inc. All rights reserved.
2
+
3
+ require_relative './queue'
4
+
5
+ class ChannelTimeout < Exception
6
+ end
7
+
8
+ # Channel represents a medium through which SignalFlow messages pass.
9
+ # The main method for it is {#each_message}, which is how you get messages from
10
+ # the channel. There can only be one user of a channel and they are NOT
11
+ # thread-safe.
12
+ #
13
+ # Channels are for one-time use only. Once a channel is detached from (either
14
+ # manually or due to the end of a computation) previous messages will be
15
+ # iterable but nothing new will show up.
16
+ class Channel
17
+ attr_accessor :name
18
+ attr_accessor :detached
19
+
20
+ def initialize(name, detach_cb)
21
+ @lock = Mutex.new
22
+ @detach_lock = Mutex.new
23
+ @detached = false
24
+ @name = name
25
+ @detach_from_transport = detach_cb
26
+ @messages = QueueWithTimeout.new
27
+ end
28
+
29
+ # Waits for and returns the next message in the channel.
30
+ #
31
+ # @param timeout_seconds [Float] Number of seconds to wait for a message.
32
+ #
33
+ # @return [Hash] The next message received by this channel. A return value
34
+ # of `nil` indicates that the channel has detected it is done and will not be
35
+ # receiving any more useful messages.
36
+ #
37
+ # @raise [ChannelTimeout] If the timeout is exceeded with no messages
38
+ def pop(timeout_seconds=nil)
39
+ raise "Channel #{@name} is detached" if @detached
40
+
41
+ msg = nil
42
+ begin
43
+ msg = @messages.pop_with_timeout(timeout_seconds)
44
+ rescue ThreadError
45
+ raise ChannelTimeout.new(
46
+ "Did not receive a message on channel #{@name} within #{timeout_seconds} seconds")
47
+ end
48
+
49
+ if msg[:event] == "END_OF_CHANNEL" || msg[:event] == "CONNECTION_CLOSED" || msg[:event] == "CHANNEL_ABORT"
50
+ # Mark this channel as detached and then return nil as an indicator that
51
+ # this channel is done
52
+ detach(false)
53
+
54
+ nil
55
+ else
56
+ msg
57
+ end
58
+
59
+ end
60
+
61
+
62
+ def detach(send_detach_to_server=true)
63
+ if !@detached
64
+ @detached = true
65
+ @detach_from_transport.call if send_detach_to_server
66
+ @detach_from_transport = nil
67
+ end
68
+ end
69
+
70
+ def inject_message(msg)
71
+ # Since messages are injected by a separate websocket thread, they could
72
+ # come in after the user has detached manually from the channel. Just
73
+ # silently ignore them in that case.
74
+ return if @detached
75
+ raise 'Cannot inject nil message' if msg.nil?
76
+
77
+ @messages << msg
78
+ end
79
+
80
+ end
@@ -0,0 +1,73 @@
1
+ # Copyright (C) 2017 SignalFx, Inc. All rights reserved.
2
+
3
+ require 'thread'
4
+
5
+ require_relative "./websocket"
6
+
7
+ # A SignalFlow client that uses the WebSockets interface.
8
+ #
9
+ # See https://developers.signalfx.com/v2/reference#signalflowconnect for
10
+ # low-level API details and information on the SignalFlow language.
11
+ #
12
+ # See
13
+ # {https://github.com/signalfx/signalfx-ruby/blob/master/examples/signalflow.rb}
14
+ # for an example script that uses the client.
15
+ #
16
+ # The messages passed into the `computation.each_message*` blocks will be
17
+ # decoded forms of what is described in
18
+ # {https://developers.signalfx.com/v2/reference#information-messages-specification
19
+ # our API reference for SignalFlow}. Hash keys will be symbols instead of
20
+ # strings.
21
+ class SignalFlowClient
22
+ def initialize(api_token, api_endpoint, stream_endpoint)
23
+ @transport = SignalFlowWebsocketTransport.new(api_token, stream_endpoint)
24
+ end
25
+
26
+ # Start a computation and attach to its output. If using WebSockets (the
27
+ # default), the channel name is handled internally so you do not need to
28
+ # supply it.
29
+ #
30
+ # See https://developers.signalfx.com/reference#section-execute-a-computation
31
+ #
32
+ # @option options [Fixnum] :start
33
+ # @option options [Fixnum] :stop
34
+ # @option options [Fixnum] :resolution
35
+ # @option options [Fixnum] :max_delay
36
+ # @option options [Boolean] :persistent
37
+ #
38
+ # @return [Computation] A {Computation} instance with an active channel
39
+ def execute(program, **options)
40
+ @transport.execute(program, **options)
41
+ end
42
+
43
+ # Start and attach to a computation that tells how many times a detector
44
+ # would have fired in a time range between `start` and `stop`.
45
+ #
46
+ # See https://developers.signalfx.com/v2/reference#signalflowpreflight
47
+ #
48
+ # @param start [Fixnum]
49
+ # @param stop [Fixnum]
50
+ # @option options [Fixnum] :max_delay
51
+ #
52
+ # @return [Computation] A {Computation} instance with an active channel
53
+ def preflight(program, start, stop, **options)
54
+ @transport.preflight(program, start, stop, **options)
55
+ end
56
+
57
+ # Start a computation without attaching to it
58
+ #
59
+ # The `publish()` call in the program must specify a `metric` to publish the
60
+ # output to since you cannot currently attach to the output.
61
+ #
62
+ # Optional parameters are the same as {#execute}.
63
+ # @return [Computation] A {Computation} instance with a handle but without a
64
+ # channel
65
+ def start(program, **options)
66
+ @transport.start(program, **options)
67
+ end
68
+
69
+ # Stop everything and close any open connections.
70
+ def close
71
+ @transport.close
72
+ end
73
+ end
@@ -0,0 +1,243 @@
1
+ # Copyright (C) 2017 SignalFx, Inc. All rights reserved.
2
+
3
+
4
+ STARTED_STATE = :started
5
+ ABORTED_STATE = :aborted
6
+ COMPLETED_STATE = :completed
7
+ DATA_STREAMING_STATE = :data_streaming
8
+
9
+ # Represents a SignalFlow computation/job. A computation can have a channel
10
+ # associated with it, but not necessarily (it could have been detached while
11
+ # the computation is still running or never attached in the first place). New
12
+ # instances should only be created by the client and a Computation MUST have a
13
+ # handle.
14
+ class Computation
15
+ attr_accessor :handle
16
+ attr_accessor :channel
17
+ attr_accessor :state
18
+ attr_accessor :metadata
19
+ attr_accessor :resolution
20
+ attr_accessor :input_timeseries_count
21
+ attr_accessor :last_timestamp_seen
22
+
23
+ def initialize(handle, attach_func, stop_func)
24
+ @handle = handle
25
+ @channel = nil
26
+ @attach_func = attach_func
27
+ @stop_func = stop_func
28
+ @metadata = {}
29
+ # We can't have a handle until the job is started so we must be at least
30
+ # at this state
31
+ @state = STARTED_STATE
32
+
33
+ @pending_messages = Queue.new
34
+
35
+ @batch_size_known = false
36
+ @expected_batch_size = 0
37
+ @current_batch_data = nil
38
+ @current_batch_size = nil
39
+
40
+ @last_timestamp_seen = nil
41
+ @resolution = nil
42
+ @input_timeseries_count = nil
43
+ end
44
+
45
+ def channel=(channel)
46
+ @channel = channel
47
+ end
48
+
49
+ def attached?
50
+ !@channel.nil? && !@channel.detached
51
+ end
52
+
53
+ # Get the next message in this computation.
54
+ #
55
+ # @param timeout_seconds [Float] If a new message does not come within this
56
+ # interval, raises a {ChannelTimeout} exception. Note that this does not
57
+ # mean that this function will return within this interval since there may
58
+ # be messages received that are part of a larger batch. If nil, will block
59
+ # indefinitely.
60
+ def next_message(timeout_seconds=nil)
61
+ raise "Computation #{@handle} is not attached to a channel" unless @channel
62
+
63
+ msg = nil
64
+ while msg.nil? && !@channel.nil?
65
+ # process_message might return no messages if it is building up a batch
66
+ msg = process_message(@channel.pop(timeout_seconds))
67
+ end
68
+ return msg
69
+ end
70
+
71
+ # Iterates over the messages asynchronously for this computation. A convenience
72
+ # function if you want to fire off multiple computations simultaneously,
73
+ # though not terribly efficient since it starts a new thread that spends a
74
+ # lot of time waiting. However, since we don't have a way of "select"ing on
75
+ # computations, this is probably good enough for basic use.
76
+ #
77
+ # See {#each_message}.
78
+ def each_message_async(&block)
79
+ raise "Computation #{@handle} is not attached to a channel" unless @channel
80
+
81
+ Thread.new{ each_message(&block) }
82
+ return
83
+ end
84
+
85
+ # Call the given block with each message in the channel as they arrive. This
86
+ # method will not return until the channel is detached from (either manually
87
+ # or due to the computation ending).
88
+ #
89
+ # Messages are queued in the channel so that none will be lost if this method
90
+ # is not called immediately.
91
+ #
92
+ # @yield [msg, comp] Called when a message arrives that is relevant to the
93
+ # channel's computation. The `comp` param will be set to this computation
94
+ # instance for easy referencing of computation metadata and state. `comp`
95
+ # may be omitted if this reference to the computation is not needed.
96
+ def each_message(&block)
97
+ raise "Computation #{@handle} is not attached to a channel" unless @channel
98
+
99
+ while @channel
100
+ msg = next_message
101
+ block.call(msg, self)
102
+ end
103
+
104
+ return
105
+ end
106
+
107
+ # Process the given message
108
+ def process_message(msg)
109
+ # nil is like EOF for channels
110
+ if msg.nil?
111
+ @channel = nil
112
+ reset_current_batch
113
+ else
114
+ # Sniff messages and update computation
115
+ case msg[:type]
116
+ when "metadata"
117
+ @metadata[msg[:tsId]] = msg[:properties]
118
+ msg
119
+
120
+ when "expired-tsid"
121
+ @metadata.delete(msg[:tsId])
122
+ msg
123
+
124
+ when "control-message"
125
+ case msg[:event]
126
+ when "CHANNEL_ABORT"
127
+ @state = ABORTED_STATE
128
+ when "END_OF_CHANNEL"
129
+ @state = COMPLETED_STATE
130
+ end
131
+
132
+ msg
133
+
134
+ when "message"
135
+ # Don't let users see any messages of this type, but use them to update
136
+ # computation state that the user can access.
137
+ case msg[:messageCode]
138
+ when 'JOB_RUNNING_RESOLUTION'
139
+ @resolution = msg[:contents][:resolutionMs]
140
+ when 'FETCH_NUM_TIMESERIES'
141
+ @input_timeseries_count += msg[:numInputTimeSeries]
142
+ end
143
+
144
+ # The server guarantees that an initial batch of data will be sent before
145
+ # the first "message" message. Therefore, when we see a message of this
146
+ # type, we know we have determined the batch size.
147
+ @batch_size_known = true
148
+ # We also know that the current batch (if any) is done
149
+ reset_current_batch
150
+
151
+ when "data"
152
+ @state = DATA_STREAMING_STATE
153
+
154
+ # The expected batch size is the number of data messages received before
155
+ # either the first arrival of a "message" message or receiving two data
156
+ # messages with different logical timestamps.
157
+ if !@batch_size_known
158
+ @expected_batch_size += 1
159
+ end
160
+
161
+ out = nil
162
+ if @current_batch_data && msg.fetch(:logicalTimestampMs) != @current_batch_data.fetch(:logicalTimestampMs)
163
+ # Two data messages back to back with different timestamps before
164
+ # receiving the first "message" message indicate that the previous
165
+ # batch is done and our batch size is now whatever the total data
166
+ # messages seen up until this point.
167
+ @batch_size_known = true
168
+ out = reset_current_batch
169
+ add_to_current_batch(msg)
170
+ else
171
+ add_to_current_batch(msg)
172
+ if @batch_size_known && @current_batch_size == @expected_batch_size
173
+ out = reset_current_batch
174
+ end
175
+ end
176
+
177
+ out
178
+
179
+ when "error"
180
+ raise ComputationFailure.new(msg[:errors])
181
+
182
+ else
183
+ msg
184
+ end
185
+ end
186
+ end
187
+ private :process_message
188
+
189
+ # Add to the current batch, initializing the current batch if not already
190
+ # set.
191
+ def add_to_current_batch(msg)
192
+ if !@current_data_batch
193
+ @current_data_batch = msg
194
+ @current_batch_size = 1
195
+ else
196
+ @current_data_batch[:data].merge!(msg.fetch(:data))
197
+ @current_batch_size += 1
198
+ end
199
+ end
200
+ private :add_to_current_batch
201
+
202
+ # Resets the current batch, returning the previous value
203
+ def reset_current_batch
204
+ msg = @current_data_batch
205
+ @current_data_batch = nil
206
+ @current_batch_size = 0
207
+ @last_timestamp_seen = msg.fetch(:logicalTimestampMs) if !msg.nil?
208
+ msg
209
+ end
210
+ private :reset_current_batch
211
+
212
+ # Attach to an already running computation.
213
+ #
214
+ # *Not currently implemented on backend!*
215
+ #
216
+ # @return [Computation] This same computation instance with a now active
217
+ # channel attached to it.
218
+ def attach(**options)
219
+ raise "Computation #{@handle} is already attached!" if @channel
220
+
221
+ @channel = @attach_func.call(@handle, **options)
222
+ self
223
+ end
224
+
225
+ # Detach from this computation and remove reference to the channel to free up
226
+ # memory.
227
+ def detach
228
+ @channel.detach
229
+ @channel = nil
230
+ end
231
+
232
+ # Stop a computation
233
+ #
234
+ # See https://developers.signalfx.com/v2/reference#section-stop-a-computation
235
+ #
236
+ # @param reason [String] Reason for stopping the computation.
237
+ def stop(reason=nil)
238
+ @stop_func.call(@handle, reason)
239
+ end
240
+ end
241
+
242
+ class ComputationFailure < Exception
243
+ end
@@ -0,0 +1,43 @@
1
+ require 'thread'
2
+
3
+ # Borrowed from
4
+ # https://spin.atomicobject.com/2017/06/28/queue-pop-with-timeout-fixed/
5
+
6
+ class QueueWithTimeout
7
+ def initialize
8
+ @mutex = Mutex.new
9
+ @queue = []
10
+ @received = ConditionVariable.new
11
+ end
12
+
13
+ def <<(x)
14
+ @mutex.synchronize do
15
+ @queue << x
16
+ @received.signal
17
+ end
18
+ end
19
+
20
+ def pop(non_block = false)
21
+ pop_with_timeout(non_block ? 0 : nil)
22
+ end
23
+
24
+ def pop_with_timeout(timeout = nil)
25
+ @mutex.synchronize do
26
+ if timeout.nil?
27
+ # wait indefinitely until there is an element in the queue
28
+ while @queue.empty?
29
+ @received.wait(@mutex)
30
+ end
31
+ elsif @queue.empty? && timeout != 0
32
+ # wait for element or timeout
33
+ timeout_time = timeout + Time.now.to_f
34
+ while @queue.empty? && (remaining_time = timeout_time - Time.now.to_f) > 0
35
+ @received.wait(@mutex, remaining_time)
36
+ end
37
+ end
38
+ #if we're still empty after the timeout, raise exception
39
+ raise ThreadError, "queue empty" if @queue.empty?
40
+ @queue.shift
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,317 @@
1
+ # Copyright (C) 2017 SignalFx, Inc. All rights reserved.
2
+
3
+ require 'json'
4
+ require 'thread'
5
+ require 'websocket-client-simple'
6
+ require 'eventmachine'
7
+
8
+ require_relative './binary'
9
+ require_relative './channel'
10
+ require_relative './computation'
11
+
12
+
13
+ # A WebSocket transport for SignalFlow. This should not be used directly by
14
+ # end-users.
15
+ class SignalFlowWebsocketTransport
16
+ DETACHED = "DETACHED"
17
+
18
+ # A lower bound on the amount of time to wait for a computation to start
19
+ COMPUTATION_START_TIMEOUT_SECONDS = 30
20
+
21
+ def initialize(api_token, stream_endpoint)
22
+ @api_token = api_token
23
+ @stream_endpoint = stream_endpoint
24
+ @compress = true
25
+
26
+ @lock = Mutex.new
27
+ @close_reason = nil
28
+ reinit
29
+ end
30
+
31
+ def reinit
32
+ @ws = nil
33
+ @authenticated = false
34
+ @chan_callbacks = {}
35
+
36
+ name_lock = Mutex.new
37
+ num = 0
38
+ # Returns a unique channel name each time it is called
39
+ @channel_namer = ->{
40
+ name_lock.synchronize do
41
+ num += 1
42
+ "channel-#{num}"
43
+ end
44
+ }
45
+ end
46
+ private :reinit
47
+
48
+ # Starts a job (either execute or preflight) and waits until the JOB_START
49
+ # message is received with the computation handle arrives so that we can
50
+ # create a properly initialized computation object. Yields to the given
51
+ # block which should send the WS message to start the job.
52
+ def start_job
53
+ computation = nil
54
+
55
+ channel = make_new_channel
56
+
57
+ yield channel.name
58
+
59
+ while true
60
+ begin
61
+ msg = channel.pop(COMPUTATION_START_TIMEOUT_SECONDS)
62
+ rescue ChannelTimeout
63
+ raise "Computation did not start after at least #{COMPUTATION_START_TIMEOUT_SECONDS} seconds"
64
+ end
65
+ if msg[:type] == "error"
66
+ raise ComputationFailure.new(msg[:message])
67
+ end
68
+
69
+ # STREAM_START comes before this but contains no useful information
70
+ if msg[:event] == "JOB_START"
71
+ computation = Computation.new(msg[:handle], method(:attach), method(:stop))
72
+ computation.channel = channel
73
+ elsif msg[:type] == "computation-started"
74
+ computation = Computation.new(msg[:computationId], method(:attach), method(:stop))
75
+ # Start jobs only use the channel to get error messages and can
76
+ # detach from the channel once the job has started.
77
+ channel.detach
78
+ else
79
+ next
80
+ end
81
+
82
+ return computation
83
+ end
84
+ end
85
+
86
+ def execute(program, start: nil, stop: nil, resolution: nil, max_delay: nil, persistent: nil)
87
+ start_job do |channel_name|
88
+ send_msg({
89
+ :type => "execute",
90
+ :channel => channel_name,
91
+ :program => program,
92
+ :start => start,
93
+ :stop => stop,
94
+ :resolution => resolution,
95
+ :max_delay => max_delay,
96
+ :persistent => persistent,
97
+ :compress => @compress,
98
+ }.reject!{|k,v| v.nil?}.to_json)
99
+ end
100
+ end
101
+
102
+ def preflight(program, start, stop, resolution: nil, max_delay: nil)
103
+ start_job do |channel_name|
104
+ send_msg({
105
+ :type => "preflight",
106
+ :channel => channel_name,
107
+ :program => program,
108
+ :start => start,
109
+ :stop => stop,
110
+ :resolution => resolution,
111
+ :max_delay => max_delay,
112
+ :compress => @compress,
113
+ }.reject!{|k,v| v.nil?}.to_json)
114
+ end
115
+ end
116
+
117
+ def start(program, start: nil, stop: nil, resolution: nil, max_delay: nil)
118
+ start_job do |channel_name|
119
+ send_msg({
120
+ :type => "start",
121
+ :channel => channel_name,
122
+ :program => program,
123
+ :start => start,
124
+ :stop => stop,
125
+ :resolution => resolution,
126
+ :max_delay => max_delay,
127
+ }.reject!{|k,v| v.nil?}.to_json)
128
+ end
129
+ end
130
+
131
+ def stop(handle, reason)
132
+ send_msg({
133
+ :type => "stop",
134
+ :handle => handle,
135
+ :reason => reason,
136
+ }.reject!{|k,v| v.nil?}.to_json)
137
+ end
138
+
139
+ # This doesn't actually work on the backend yet
140
+ def attach(handle, filters: nil, resolution: nil)
141
+ channel = make_new_channel
142
+
143
+ send_msg({
144
+ :type => "attach",
145
+ :channel => channel.name,
146
+ :handle => handle,
147
+ :filters => filters,
148
+ :resolution => resolution,
149
+ :compress => @compress,
150
+ }.reject!{|k,v| v.nil?}.to_json)
151
+
152
+ channel
153
+ end
154
+
155
+ def detach(channel, reason=nil)
156
+ send_msg({
157
+ :type => "detach",
158
+ :channel => channel,
159
+ :reason => reason,
160
+ }.to_json)
161
+
162
+ # There is no response message from the server signifying detach complete
163
+ # and there could be messages coming in even after the detach request is
164
+ # sent. Therefore, use a sentinal value in place of the callback block so
165
+ # that the message receiver logic can distinguish this case from some
166
+ # anomolous case (say, due to bad logic in the code).
167
+ @chan_callbacks[channel] = DETACHED
168
+ end
169
+
170
+ def close
171
+ if @ws
172
+ @ws.close
173
+ end
174
+ end
175
+
176
+ def send_msg(msg)
177
+ @lock.synchronize do
178
+ if @ws.nil?
179
+ startup_client
180
+
181
+ # Polling is the simplest and most robust way to handle blocking until
182
+ # authenticated. Using ConditionVariable requires more complex logic
183
+ # that gains very little in terms of efficiecy given how quick auth
184
+ # should be.
185
+ start_time = Time.now
186
+ while !@authenticated
187
+ # The socket will be closed by the server if auth isn't successful
188
+ # within 5 seconds so no point in waiting longer
189
+ if Time.now - start_time > 5 || @close_reason
190
+ raise "Could not authenticate to SignalFlow WebSocket: #{@close_reason}"
191
+ end
192
+ sleep 0.1
193
+ end
194
+ end
195
+
196
+ @ws.send(msg)
197
+ end
198
+ end
199
+ private :send_msg
200
+
201
+ def on_close(msg)
202
+ @close_reason = "(#{msg.code}, #{msg.data})"
203
+ @chan_callbacks.keys.each do |channel_name|
204
+ invoke_callback_for_channel({ :event => "CONNECTION_CLOSED" }, channel_name)
205
+ end
206
+
207
+ reinit
208
+ end
209
+
210
+ def on_message(m)
211
+ begin
212
+ return if m.type == :ping
213
+ if m.type == :close
214
+ on_close(m)
215
+ return
216
+ end
217
+
218
+ message_received(m.data, m.type == :text)
219
+ rescue Exception => e
220
+ puts "Error processing SignalFlow message: #{e.backtrace.first}: #{e.message} (#{e.class})"
221
+ end
222
+ end
223
+
224
+ def on_open
225
+ @ws.send({
226
+ :type => "authenticate",
227
+ :token => @api_token,
228
+ }.to_json)
229
+ end
230
+
231
+ # Start up a new WS client in its own thread that runs an EventMachine
232
+ # reactor.
233
+ def startup_client
234
+ this = self
235
+ WebSocket::Client::Simple.connect("#{@stream_endpoint}/v2/signalflow/connect",
236
+ # Verification is disabled by default so this is essential
237
+ {verify_mode: OpenSSL::SSL::VERIFY_PEER}) do |ws|
238
+ @ws = ws
239
+ ws.on :error do |e|
240
+ puts "ERROR #{e.inspect}"
241
+ end
242
+
243
+ ws.on :close do |e|
244
+ this.on_close(e)
245
+ end
246
+
247
+ ws.on :message do |m|
248
+ this.on_message(m)
249
+ end
250
+
251
+ ws.on :open do
252
+ this.on_open
253
+ end
254
+ end
255
+ end
256
+ private :startup_client
257
+
258
+ def invoke_callback_for_channel(msg, channel_name)
259
+ chan = @chan_callbacks[channel_name]
260
+
261
+ raise "Callback for channel #{channel_name} is missing!" unless chan
262
+
263
+ if chan == DETACHED
264
+ return
265
+ else
266
+ chan.inject_message(msg)
267
+ end
268
+ end
269
+ private :invoke_callback_for_channel
270
+
271
+ def message_received(raw_msg, is_text)
272
+ msg = add_parsed_timestamp!(parse_message(raw_msg, is_text))
273
+
274
+ if msg[:type] == "authenticated"
275
+ @authenticated = true
276
+ return
277
+ end
278
+
279
+ if msg[:channel]
280
+ invoke_callback_for_channel(msg, msg[:channel])
281
+ else
282
+ # Ignore keep-alives
283
+ if msg[:event] == "KEEP_ALIVE"
284
+ return
285
+ else
286
+ raise "Unknown SignalFlow message: #{msg}"
287
+ end
288
+ end
289
+ end
290
+ private :message_received
291
+
292
+ def parse_message(raw_msg, is_text)
293
+ if is_text
294
+ JSON.parse(raw_msg, {:symbolize_names => true})
295
+ else
296
+ BinaryMessageParser.parse(raw_msg)
297
+ end
298
+ end
299
+ private :parse_message
300
+
301
+ def add_parsed_timestamp!(msg)
302
+ if msg.has_key?(:timestampMs)
303
+ msg[:timestamp] = Time.at(msg[:timestampMs] / 1000.0)
304
+ end
305
+ msg
306
+ end
307
+ private :add_parsed_timestamp!
308
+
309
+ def make_new_channel
310
+ name = @channel_namer.()
311
+ channel = Channel.new(name, ->(){ detach(name) })
312
+ @chan_callbacks[name] = channel
313
+ channel
314
+ end
315
+ private :make_new_channel
316
+ end
317
+
@@ -1,6 +1,6 @@
1
1
  # Copyright (C) 2015-2016 SignalFx, Inc. All rights reserved.
2
2
 
3
3
  module Version
4
- VERSION = '1.0.2'
4
+ VERSION = '2.0.1'
5
5
  NAME = 'signalfx-ruby-client'
6
6
  end
data/signalfx.gemspec CHANGED
@@ -10,7 +10,7 @@ Gem::Specification.new do |spec|
10
10
  spec.email = ["info@signalfx.com"]
11
11
 
12
12
  spec.summary = "Ruby client library for SignalFx"
13
- spec.description = "This is a programmatic interface in Ruby for SignalFx's metadata and ingest APIs. It is meant to provide a base for communicating with SignalFx APIs that can be easily leveraged by scripts and applications to interact with SignalFx or report metric and event data to SignalFx. Library supports Ruby 2.x versions"
13
+ spec.description = "This is a programmatic interface in Ruby for SignalFx's metadata and ingest APIs. It is meant to provide a base for communicating with SignalFx APIs that can be easily leveraged by scripts and applications to interact with SignalFx or report metric and event data to SignalFx. Library supports Ruby 2.2.x+ versions"
14
14
  spec.homepage = "https://signalfx.com"
15
15
  spec.license = "Apache Software License v2 © SignalFx"
16
16
 
@@ -33,6 +33,10 @@ Gem::Specification.new do |spec|
33
33
  spec.add_development_dependency "rake", "~> 10.0"
34
34
  spec.add_development_dependency "rspec", "~> 3.3"
35
35
  spec.add_development_dependency "webmock", "~> 2.1"
36
+ spec.add_development_dependency "thin", "~> 1.7"
37
+ spec.add_development_dependency "pry"
38
+ spec.add_development_dependency "faye-websocket", "~> 0.10.7"
36
39
  spec.add_dependency "protobuf", "~> 3.5.1", ">= 3.5.1"
37
40
  spec.add_dependency "rest-client", "~> 2.0"
41
+ spec.add_dependency 'websocket-client-simple', "~> 0.3.0"
38
42
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: signalfx
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.2
4
+ version: 2.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - SignalFx, Inc
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2016-09-19 00:00:00.000000000 Z
11
+ date: 2017-09-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -66,6 +66,48 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '2.1'
69
+ - !ruby/object:Gem::Dependency
70
+ name: thin
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.7'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.7'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pry
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: faye-websocket
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: 0.10.7
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: 0.10.7
69
111
  - !ruby/object:Gem::Dependency
70
112
  name: protobuf
71
113
  requirement: !ruby/object:Gem::Requirement
@@ -100,10 +142,24 @@ dependencies:
100
142
  - - "~>"
101
143
  - !ruby/object:Gem::Version
102
144
  version: '2.0'
145
+ - !ruby/object:Gem::Dependency
146
+ name: websocket-client-simple
147
+ requirement: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: 0.3.0
152
+ type: :runtime
153
+ prerelease: false
154
+ version_requirements: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - "~>"
157
+ - !ruby/object:Gem::Version
158
+ version: 0.3.0
103
159
  description: This is a programmatic interface in Ruby for SignalFx's metadata and
104
160
  ingest APIs. It is meant to provide a base for communicating with SignalFx APIs
105
161
  that can be easily leveraged by scripts and applications to interact with SignalFx
106
- or report metric and event data to SignalFx. Library supports Ruby 2.x versions
162
+ or report metric and event data to SignalFx. Library supports Ruby 2.2.x+ versions
107
163
  email:
108
164
  - info@signalfx.com
109
165
  executables: []
@@ -119,12 +175,19 @@ files:
119
175
  - bin/console
120
176
  - bin/setup
121
177
  - examples/generic_usecase.rb
178
+ - examples/signalflow.rb
122
179
  - lib/proto/signal_fx_protocol_buffers.pb.rb
123
180
  - lib/signalfx.rb
124
181
  - lib/signalfx/conf.rb
125
182
  - lib/signalfx/json_signal_fx_client.rb
126
183
  - lib/signalfx/protobuf_signal_fx_client.rb
127
184
  - lib/signalfx/signal_fx_client.rb
185
+ - lib/signalfx/signalflow/binary.rb
186
+ - lib/signalfx/signalflow/channel.rb
187
+ - lib/signalfx/signalflow/client.rb
188
+ - lib/signalfx/signalflow/computation.rb
189
+ - lib/signalfx/signalflow/queue.rb
190
+ - lib/signalfx/signalflow/websocket.rb
128
191
  - lib/signalfx/version.rb
129
192
  - signalfx.gemspec
130
193
  homepage: https://signalfx.com
@@ -147,7 +210,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
147
210
  version: '0'
148
211
  requirements: []
149
212
  rubyforge_project:
150
- rubygems_version: 2.4.5.1
213
+ rubygems_version: 2.6.10
151
214
  signing_key:
152
215
  specification_version: 4
153
216
  summary: Ruby client library for SignalFx